diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 07cb9f66c..574c87ad3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -5,11 +5,14 @@ PLEASE READ THESE GUIDELINES CAREFULLY BEFORE ANY CONTRIBUTION! ## Crash reporting -Do not report crashes in the GitHub issue tracker. NewPipe has an automated crash report system that will ask you to send a report via e-mail when a crash occurs. This contains all the data we need for debugging, and allows you to even add a comment to it. You'll see exactly what is sent, the system is 100% transparent. +Do not report crashes in the GitHub issue tracker. NewPipe has an automated crash report system that will ask you to +send a report via e-mail when a crash occurs. This contains all the data we need for debugging, and allows you to even +add a comment to it. You'll see exactly what is sent, the system is 100% transparent. ## Issue reporting/feature requests -* Search the [existing issues](https://github.com/TeamNewPipe/NewPipe/issues) first to make sure your issue/feature hasn't been reported/requested before +* Search the [existing issues](https://github.com/TeamNewPipe/NewPipe/issues) first to make sure your issue/feature +hasn't been reported/requested before * Check whether your issue/feature is already fixed/implemented * Check if the issue still exists in the latest release/beta version * If you are an Android/Java developer, you are always welcome to fix/implement an issue/a feature yourself. PRs welcome! @@ -19,30 +22,47 @@ Do not report crashes in the GitHub issue tracker. NewPipe has an automated cras * Issues that only contain a generated bug report, but no describtion might be closed. ## Bug Fixing -* If you want to help NewPipe to become free of bugs (this is our utopic goal for NewPipe), you can send us an email to tnp@newpipe.schabi.org to let me know that you intend to help. We'll send you further instructions. You may, on request, register at our [Sentry](https://sentry.schabi.org) instance (see section "Crash reporting" for more information. +* If you want to help NewPipe to become free of bugs (this is our utopic goal for NewPipe), you can send us an email to +tnp@newpipe.schabi.org to let me know that you intend to help. We'll send you further instructions. You may, on request, +register at our [Sentry](https://sentry.schabi.org) instance (see section "Crash reporting" for more information. ## Translation -* NewPipe can be translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). You can log in there with your GitHub account. +* NewPipe can be translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). You can log in there +with your GitHub account. ## Code contribution * Stick to NewPipe's style conventions (well, just look the other code and then do it the same way :)) -* Do not bring non-free software (e.g., binary blobs) into the project. Also, make sure you do not introduce Google libraries. +* Do not bring non-free software (e.g., binary blobs) into the project. Also, make sure you do not introduce Google + libraries. * Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy) -* Make changes on a separate branch, not on the master branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request on GitHub. Patches to the email address mentioned in this document might not be considered, GitHub is the primary platform. (This only affects you if you are a member of TeamNewPipe) -* When submitting changes, you confirm that your code is licensed under the terms of the [GNU General Public License v3](https://www.gnu.org/licenses/gpl-3.0.html). -* Please test (compile and run) your code before you submit changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! +* Make changes on a separate branch, not on the master branch. This is commonly known as *feature branch workflow*. You + may then send your changes as a pull request on GitHub. Patches to the email address mentioned in this document might + not be considered, GitHub is the primary platform. (This only affects you if you are a member of TeamNewPipe) +* When submitting changes, you confirm that your code is licensed under the terms of the + [GNU General Public License v3](https://www.gnu.org/licenses/gpl-3.0.html). +* Please test (compile and run) your code before you submit changes! Ideally, provide test feedback in the PR + description. Untested code will **not** be merged! * Try to figure out yourself why builds on our CI fail. -* Make sure your PR is up-to-date with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you are asked to merge the master branch manually and resolve the problems on your own. That will make the maintainers' jobs way easier. -* Please show intention to maintain your features and code after you contributed it. Unmaintained code is a hassle for the core developers, and just adds work. If you do not intend to maintain features you contributed, please think again about submission, or clearly state that in the description of your PR. +* Make sure your PR is up-to-date with the rest of the code. Often, a simple click on "Update branch" will do the job, + but if not, you are asked to merge the master branch manually and resolve the problems on your own. That will make the + maintainers' jobs way easier. +* Please show intention to maintain your features and code after you contributed it. Unmaintained code is a hassle for + the core developers, and just adds work. If you do not intend to maintain features you contributed, please think again + about submission, or clearly state that in the description of your PR. * Respond yourselves if someone requests changes or otherwise raises issues about your PRs. * Check if your contributions align with the [fdroid inclusion guidelines](https://f-droid.org/en/docs/Inclusion_Policy/). * Check if your submission can be build with the current fdroid build server setup. +* Send PR that only cover one specific issue/solution/bug. Do not send PRs that are huge and consists of multiple + independent solutions. ## Communication * WE DO NOW HAVE A MAILING LIST: [newpipe@list.schabi.org](https://list.schabi.org/cgi-bin/mailman/listinfo/newpipe). -* There is an IRC channel on Freenode which is regularly visited by the core team and other developers: [#newpipe](irc:irc.freenode.net/newpipe). [Click here for Webchat](https://webchat.freenode.net/?channels=newpipe)! -* If you want to get in touch with the core team or one of our other contributors you can send an email to tnp(at)schabi.org. Please do not send issue reports, they will be ignored and remain unanswered! Use the GitHub issue tracker described above! +* There is an IRC channel on Freenode which is regularly visited by the core team and other developers: + [#newpipe](irc:irc.freenode.net/newpipe). [Click here for Webchat](https://webchat.freenode.net/?channels=newpipe)! +* If you want to get in touch with the core team or one of our other contributors you can send an email to + tnp(at)schabi.org. Please do not send issue reports, they will be ignored and remain unanswered! Use the GitHub issue + tracker described above! * Feel free to post suggestions, changes, ideas etc. on GitHub, IRC or the mailing list! diff --git a/.travis.yml b/.travis.yml index d5d3aed9c..d6f97ab55 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,13 @@ android: components: # The BuildTools version used by NewPipe - tools - - build-tools-27.0.3 + - build-tools-28.0.3 # The SDK version used to compile NewPipe - - android-27 + - android-28 before_install: - - yes | sdkmanager "platforms;android-27" + - yes | sdkmanager "platforms;android-28" script: ./gradlew -Dorg.gradle.jvmargs=-Xmx1536m assembleDebug lintDebug testDebugUnitTest licenses: diff --git a/README.md b/README.md index b154fad58..15ba3d04b 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,77 @@ -

+

NewPipe

-

A free lightweight YouTube frontend for Android.

-

+

A libre lightweight streaming frontend for Android.

+

- - - - - - + + + + + +

-
+

ScreenshotsDescriptionFeaturesContributionDonateLicense

WebsiteBlogPress

-
-WARNING: PUTTING NEWPIPE OR ANY FORK OF IT INTO GOOGLE PLAYSTORE VIOLATES THEIR TERMS OF CONDITIONS. +
+ +WARNING: PUTTING NEWPIPE OR ANY FORK OF IT INTO GOOGLE PLAYSTORE VIOLATES THEIR TERMS OF CONDITIONS. ## Screenshots -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_1.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_2.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_3.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_4.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_5.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_6.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_7.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_8.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_9.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png) +[](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png) +[](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png) ## Description -NewPipe does not use any Google framework libraries, or the YouTube API. It only parses the website in order to gain the information it needs. Therefore this app can be used on devices without Google Services installed. Also, you don't need a YouTube account to use NewPipe, and it's FLOSS. +NewPipe does not use any Google framework libraries, nor the YouTube API. Websites are only parsed to fetch required info, so this app can be used on devices without Google services installed. Also, you don't need a YouTube account to use NewPipe, which is copylefted libre software. ### Features * Search videos -* Display general information about a video +* Display general info about videos * Watch YouTube videos * Listen to YouTube videos * Popup mode (floating player) -* Select the streaming player to watch the video with -* Download videos +* Select streaming player to watch video with +* Download videos * Download audio only * Open a video in Kodi -* Show Next/Related videos +* Show next/related videos * Search YouTube in a specific language * Watch/Block age restricted material -* Display general information about channels +* Display general info about channels * Search channels * Watch videos from a channel * Orbot/Tor support (not yet directly) -* 1080p/2k/4k support +* 1080p/2K/4K support * View history * Subscribe to channels * Search history -* Search/Watch Playlists -* Watch as queues Playlists -* Queuing videos +* Search/watch playlists +* Watch as enqueued playlists +* Enqueue videos * Local playlists * Subtitles -* Multi-service support (eg. SoundCloud in NewPipe Beta) +* Multi-service support (e.g. SoundCloud \[beta\]) +* Livestream support ### Coming Features -* Livestream support * Cast to UPnP and Cast * Show comments -* ... and many more +* … and many more ## Contribution Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome. @@ -77,26 +80,31 @@ The more is done the better it gets! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). ## Donate -If you like NewPipe we'd be happy about a donation. You can either donate via Bitcoin, Bountysource or Liberapay. For further information about donating to NewPipe, please visit our [website](https://newpipe.schabi.org/donate). +If you like NewPipe we'd be happy about a donation. You can either send bitcoin or donate via Bountysource or Liberapay. For further info on donating to NewPipe, please visit our [website](https://newpipe.schabi.org/donate). - - + + - - - + + + - - - + + +
BitcoinBitcoin QR CodeBitcoinBitcoin QR code 16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh
LiberapayVisit NewPipe at liberapay.comDonate via LiberapayLiberapayVisit NewPipe at liberapay.comDonate via Liberapay
BountysourceVisit NewPipe at bountysource.comCheck out how many bounties you can earn.BountysourceVisit NewPipe at bountysource.comCheck out how many bounties you can earn.
+## Privacy Policy + +The NewPipe project aims to provide a private, anonymous experience for using media web services. +Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or comment in our blog. You can find the document [here](https://newpipe.schabi.org/legal/privacy/). + ## License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) diff --git a/app/build.gradle b/app/build.gradle index 7ba0ef803..31ec4776a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,15 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 27 - buildToolsVersion '27.0.3' + compileSdkVersion 28 + buildToolsVersion '28.0.3' defaultConfig { applicationId "org.schabi.newpipe" - minSdkVersion 15 - targetSdkVersion 27 - versionCode 68 - versionName "0.14.1" + minSdkVersion 19 + targetSdkVersion 28 + versionCode 69 + versionName "0.14.2" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -22,7 +22,6 @@ android { } debug { multiDexEnabled true - debuggable true applicationIdSuffix ".debug" } @@ -41,62 +40,61 @@ android { } ext { - supportLibVersion = '27.1.1' - exoPlayerLibVersion = '2.8.2' + supportLibVersion = '28.0.0' + exoPlayerLibVersion = '2.8.4' //2.9.0 roomDbLibVersion = '1.1.1' - leakCanaryLibVersion = '1.5.4' - okHttpLibVersion = '3.10.0' + leakCanaryLibVersion = '1.5.4' //1.6.1 + okHttpLibVersion = '3.11.0' icepickLibVersion = '3.2.0' stethoLibVersion = '1.5.0' } dependencies { - androidTestImplementation('com.android.support.test.espresso:espresso-core:2.2.2') { + androidTestImplementation('com.android.support.test.espresso:espresso-core:3.0.2', { exclude module: 'support-annotations' - } + }) - implementation 'com.github.yausername:NewPipeExtractor:4883b6f' + implementation 'com.github.yausername:NewPipeExtractor:df0db84' testImplementation 'junit:junit:4.12' - testImplementation 'org.mockito:mockito-core:2.8.9' + testImplementation 'org.mockito:mockito-core:2.23.0' - implementation "com.android.support:appcompat-v7:$supportLibVersion" - implementation "com.android.support:support-v4:$supportLibVersion" - implementation "com.android.support:design:$supportLibVersion" - implementation "com.android.support:recyclerview-v7:$supportLibVersion" - implementation "com.android.support:preference-v14:$supportLibVersion" + implementation "com.android.support:appcompat-v7:${supportLibVersion}" + implementation "com.android.support:support-v4:${supportLibVersion}" + implementation "com.android.support:design:${supportLibVersion}" + implementation "com.android.support:recyclerview-v7:${supportLibVersion}" + implementation "com.android.support:preference-v14:${supportLibVersion}" + implementation "com.android.support:cardview-v7:${supportLibVersion}" + implementation 'com.android.support.constraint:constraint-layout:1.1.3' - implementation 'ch.acra:acra:4.9.2' + implementation 'ch.acra:acra:4.9.2' //4.11 implementation 'com.nostra13.universalimageloader:universal-image-loader:1.9.5' implementation 'de.hdodenhof:circleimageview:2.2.0' implementation 'com.github.nirhart:ParallaxScroll:dd53d1f9d1' implementation 'com.nononsenseapps:filepicker:4.2.1' - implementation "com.google.android.exoplayer:exoplayer:$exoPlayerLibVersion" - implementation "com.google.android.exoplayer:extension-mediasession:$exoPlayerLibVersion" + implementation "com.google.android.exoplayer:exoplayer:${exoPlayerLibVersion}" + implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerLibVersion}" - debugImplementation "com.facebook.stetho:stetho:$stethoLibVersion" - debugImplementation "com.facebook.stetho:stetho-urlconnection:$stethoLibVersion" + debugImplementation "com.facebook.stetho:stetho:${stethoLibVersion}" + debugImplementation "com.facebook.stetho:stetho-urlconnection:${stethoLibVersion}" debugImplementation 'com.android.support:multidex:1.0.3' - implementation 'io.reactivex.rxjava2:rxjava:2.1.14' - implementation 'io.reactivex.rxjava2:rxandroid:2.0.2' + implementation 'io.reactivex.rxjava2:rxjava:2.2.2' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.0' implementation 'com.jakewharton.rxbinding2:rxbinding:2.1.1' - implementation "android.arch.persistence.room:runtime:$roomDbLibVersion" - implementation "android.arch.persistence.room:rxjava2:$roomDbLibVersion" - annotationProcessor "android.arch.persistence.room:compiler:$roomDbLibVersion" + implementation "android.arch.persistence.room:runtime:${roomDbLibVersion}" + implementation "android.arch.persistence.room:rxjava2:${roomDbLibVersion}" + annotationProcessor "android.arch.persistence.room:compiler:${roomDbLibVersion}" - implementation "frankiesardo:icepick:$icepickLibVersion" - annotationProcessor "frankiesardo:icepick-processor:$icepickLibVersion" + implementation "frankiesardo:icepick:${icepickLibVersion}" + annotationProcessor "frankiesardo:icepick-processor:${icepickLibVersion}" - debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakCanaryLibVersion" - releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:$leakCanaryLibVersion" + debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryLibVersion}" + releaseImplementation "com.squareup.leakcanary:leakcanary-android-no-op:${leakCanaryLibVersion}" - - implementation "com.squareup.okhttp3:okhttp:$okHttpLibVersion" - debugImplementation "com.facebook.stetho:stetho-okhttp3:$stethoLibVersion" - implementation 'com.android.support.constraint:constraint-layout:1.1.2' - implementation 'com.android.support:cardview-v7:27.1.1' + implementation "com.squareup.okhttp3:okhttp:${okHttpLibVersion}" + debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoLibVersion}" } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e4d448184..1bc205f33 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,7 @@ + - \ No newline at end of file + diff --git a/app/src/main/java/android/support/design/widget/FlingBehavior.java b/app/src/main/java/android/support/design/widget/FlingBehavior.java new file mode 100644 index 000000000..59eb08294 --- /dev/null +++ b/app/src/main/java/android/support/design/widget/FlingBehavior.java @@ -0,0 +1,116 @@ +package android.support.design.widget; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.design.animation.AnimationUtils; +import android.util.AttributeSet; +import android.view.View; + +// check this https://github.com/ToDou/appbarlayout-spring-behavior/blob/master/appbarspring/src/main/java/android/support/design/widget/AppBarFlingFixBehavior.java +public final class FlingBehavior extends AppBarLayout.Behavior { + + private ValueAnimator mOffsetAnimator; + private static final int MAX_OFFSET_ANIMATION_DURATION = 600; // ms + + public FlingBehavior() { + } + + public FlingBehavior(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void onNestedPreScroll(CoordinatorLayout coordinatorLayout, AppBarLayout child, View target, int dx, int dy, int[] consumed, int type) { + if (dy != 0) { + int val = child.getBottom(); + if (val != 0) { + int min, max; + if (dy < 0) { + // We're scrolling down + } else { + // We're scrolling up + if (mOffsetAnimator != null && mOffsetAnimator.isRunning()) { + mOffsetAnimator.cancel(); + } + min = -child.getUpNestedPreScrollRange(); + max = 0; + consumed[1] = scroll(coordinatorLayout, child, dy, min, max); + } + } + } + } + + @Override + public boolean onNestedPreFling(@NonNull CoordinatorLayout coordinatorLayout, @NonNull AppBarLayout child, @NonNull View target, float velocityX, float velocityY) { + + if (velocityY != 0) { + if (velocityY < 0) { + // We're flinging down + int val = child.getBottom(); + if (val != 0) { + final int targetScroll = + +child.getDownNestedPreScrollRange(); + animateOffsetTo(coordinatorLayout, child, targetScroll, velocityY); + } + + } else { + // We're flinging up + int val = child.getBottom(); + if (val != 0) { + final int targetScroll = -child.getUpNestedPreScrollRange(); + if (getTopBottomOffsetForScrollingSibling() > targetScroll) { + animateOffsetTo(coordinatorLayout, child, targetScroll, velocityY); + } + } + } + } + + return super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY); + } + + private void animateOffsetTo(final CoordinatorLayout coordinatorLayout, + final AppBarLayout child, final int offset, float velocity) { + final int distance = Math.abs(getTopBottomOffsetForScrollingSibling() - offset); + + final int duration; + velocity = Math.abs(velocity); + if (velocity > 0) { + duration = 3 * Math.round(1000 * (distance / velocity)); + } else { + final float distanceRatio = (float) distance / child.getHeight(); + duration = (int) ((distanceRatio + 1) * 150); + } + + animateOffsetWithDuration(coordinatorLayout, child, offset, duration); + } + + private void animateOffsetWithDuration(final CoordinatorLayout coordinatorLayout, + final AppBarLayout child, final int offset, final int duration) { + final int currentOffset = getTopBottomOffsetForScrollingSibling(); + if (currentOffset == offset) { + if (mOffsetAnimator != null && mOffsetAnimator.isRunning()) { + mOffsetAnimator.cancel(); + } + return; + } + + if (mOffsetAnimator == null) { + mOffsetAnimator = new ValueAnimator(); + mOffsetAnimator.setInterpolator(AnimationUtils.DECELERATE_INTERPOLATOR); + mOffsetAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(ValueAnimator animator) { + setHeaderTopBottomOffset(coordinatorLayout, child, + (Integer) animator.getAnimatedValue()); + } + }); + } else { + mOffsetAnimator.cancel(); + } + + mOffsetAnimator.setDuration(Math.min(duration, MAX_OFFSET_ANIMATION_DURATION)); + mOffsetAnimator.setIntValues(currentOffset, offset); + mOffsetAnimator.start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index c0af70efb..314c95c8d 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -5,6 +5,7 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Build; +import android.preference.PreferenceManager; import android.support.annotation.Nullable; import android.util.Log; @@ -66,7 +67,8 @@ public class App extends Application { private RefWatcher refWatcher; @SuppressWarnings("unchecked") - private static final Class[] reportSenderFactoryClasses = new Class[]{AcraReportSenderFactory.class}; + private static final Class[] + reportSenderFactoryClasses = new Class[]{AcraReportSenderFactory.class}; @Override protected void attachBaseContext(Context base) { @@ -89,7 +91,8 @@ public class App extends Application { // Initialize settings first because others inits can use its values SettingsActivity.initSettings(this); - NewPipe.init(getDownloader(), new Localization("GB", "en")); + NewPipe.init(getDownloader(), + org.schabi.newpipe.util.Localization.getPreferredExtractorLocal(this)); StateSaver.init(this); initNotificationChannel(); @@ -181,7 +184,11 @@ public class App extends Application { ACRA.init(this, acraConfig); } catch (ACRAConfigurationException ace) { ace.printStackTrace(); - ErrorActivity.reportError(this, ace, null, null, ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + ErrorActivity.reportError(this, + ace, + null, + null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", "Could not initialize ACRA crash report", R.string.app_ui_crash)); } } @@ -201,7 +208,8 @@ public class App extends Application { NotificationChannel mChannel = new NotificationChannel(id, name, importance); mChannel.setDescription(description); - NotificationManager mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager mNotificationManager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); mNotificationManager.createNotificationChannel(mChannel); } diff --git a/app/src/main/java/org/schabi/newpipe/Downloader.java b/app/src/main/java/org/schabi/newpipe/Downloader.java index 8ff2b839a..531a52b78 100644 --- a/app/src/main/java/org/schabi/newpipe/Downloader.java +++ b/app/src/main/java/org/schabi/newpipe/Downloader.java @@ -10,7 +10,6 @@ import org.schabi.newpipe.extractor.utils.Localization; import java.io.IOException; import java.io.InputStream; -import java.io.Serializable; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -95,7 +94,8 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { .build(); response = client.newCall(request).execute(); - return Long.parseLong(response.header("Content-Length")); + String contentLength = response.header("Content-Length"); + return contentLength == null ? -1 : Long.parseLong(contentLength); } catch (NumberFormatException e) { throw new IOException("Invalid content length", e); } finally { @@ -110,7 +110,7 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { * but set the HTTP header field "Accept-Language" to the supplied string. * * @param siteUrl the URL of the text file to return the contents of - * @param localization the language and country (usually a 2-character code for both values) + * @param localization the language and country (usually a 2-character code) to set * @return the contents of the specified text file */ @Override diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index e22e2f474..b8941670f 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -542,8 +542,7 @@ public class RouterActivity extends AppCompatActivity { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); boolean isExtVideoEnabled = preferences.getBoolean(getString(R.string.use_external_video_player_key), false); - boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false); - boolean useOldVideoPlayer = PlayerHelper.isUsingOldPlayer(this); + boolean isExtAudioEnabled = preferences.getBoolean(getString(R.string.use_external_audio_player_key), false);; PlayQueue playQueue; String playerChoice = choice.playerChoice; @@ -555,9 +554,6 @@ public class RouterActivity extends AppCompatActivity { } else if (playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); - } else if (playerChoice.equals(videoPlayerKey) && useOldVideoPlayer) { - NavigationHelper.playOnOldVideoPlayer(this, (StreamInfo) info); - } else { playQueue = new SinglePlayQueue((StreamInfo) info); diff --git a/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java b/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java deleted file mode 100644 index 5a2d4a486..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DeleteDownloadManager.java +++ /dev/null @@ -1,158 +0,0 @@ -package org.schabi.newpipe.download; - -import android.app.Activity; -import android.os.Bundle; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.design.widget.BaseTransientBottomBar; -import android.support.design.widget.Snackbar; -import android.view.View; - -import org.schabi.newpipe.R; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Completable; -import io.reactivex.Observable; -import io.reactivex.android.schedulers.AndroidSchedulers; -import io.reactivex.disposables.Disposable; -import io.reactivex.schedulers.Schedulers; -import io.reactivex.subjects.PublishSubject; -import us.shandian.giga.get.DownloadManager; -import us.shandian.giga.get.DownloadMission; - -public class DeleteDownloadManager { - - private static final String KEY_STATE = "delete_manager_state"; - - private final View mView; - private final HashSet mPendingMap; - private final List mDisposableList; - private DownloadManager mDownloadManager; - private final PublishSubject publishSubject = PublishSubject.create(); - - DeleteDownloadManager(Activity activity) { - mPendingMap = new HashSet<>(); - mDisposableList = new ArrayList<>(); - mView = activity.findViewById(android.R.id.content); - } - - public Observable getUndoObservable() { - return publishSubject; - } - - public boolean contains(@NonNull DownloadMission mission) { - return mPendingMap.contains(mission.url); - } - - public void add(@NonNull DownloadMission mission) { - mPendingMap.add(mission.url); - - if (mPendingMap.size() == 1) { - showUndoDeleteSnackbar(mission); - } - } - - public void setDownloadManager(@NonNull DownloadManager downloadManager) { - mDownloadManager = downloadManager; - - if (mPendingMap.size() < 1) return; - - showUndoDeleteSnackbar(); - } - - public void restoreState(@Nullable Bundle savedInstanceState) { - if (savedInstanceState == null) return; - - List list = savedInstanceState.getStringArrayList(KEY_STATE); - if (list != null) { - mPendingMap.addAll(list); - } - } - - public void saveState(@Nullable Bundle outState) { - if (outState == null) return; - - for (Disposable disposable : mDisposableList) { - disposable.dispose(); - } - - outState.putStringArrayList(KEY_STATE, new ArrayList<>(mPendingMap)); - } - - private void showUndoDeleteSnackbar() { - if (mPendingMap.size() < 1) return; - - String url = mPendingMap.iterator().next(); - - for (int i = 0; i < mDownloadManager.getCount(); i++) { - DownloadMission mission = mDownloadManager.getMission(i); - if (url.equals(mission.url)) { - showUndoDeleteSnackbar(mission); - break; - } - } - } - - private void showUndoDeleteSnackbar(@NonNull DownloadMission mission) { - final Snackbar snackbar = Snackbar.make(mView, mission.name, Snackbar.LENGTH_INDEFINITE); - final Disposable disposable = Observable.timer(3, TimeUnit.SECONDS) - .subscribeOn(AndroidSchedulers.mainThread()) - .subscribe(l -> snackbar.dismiss()); - - mDisposableList.add(disposable); - - snackbar.setAction(R.string.undo, v -> { - mPendingMap.remove(mission.url); - publishSubject.onNext(mission); - disposable.dispose(); - snackbar.dismiss(); - }); - - snackbar.addCallback(new BaseTransientBottomBar.BaseCallback() { - @Override - public void onDismissed(Snackbar transientBottomBar, int event) { - if (!disposable.isDisposed()) { - Completable.fromAction(() -> deletePending(mission)) - .subscribeOn(Schedulers.io()) - .subscribe(); - } - mPendingMap.remove(mission.url); - snackbar.removeCallback(this); - mDisposableList.remove(disposable); - showUndoDeleteSnackbar(); - } - }); - - snackbar.show(); - } - - public void deletePending() { - if (mPendingMap.size() < 1) return; - - HashSet idSet = new HashSet<>(); - for (int i = 0; i < mDownloadManager.getCount(); i++) { - if (contains(mDownloadManager.getMission(i))) { - idSet.add(i); - } - } - - for (Integer id : idSet) { - mDownloadManager.deleteMission(id); - } - - mPendingMap.clear(); - } - - private void deletePending(@NonNull DownloadMission mission) { - for (int i = 0; i < mDownloadManager.getCount(); i++) { - if (mission.url.equals(mDownloadManager.getMission(i).url)) { - mDownloadManager.deleteMission(i); - break; - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java index 4a2c85149..251e4c730 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java @@ -15,16 +15,12 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.ThemeHelper; -import io.reactivex.Completable; -import io.reactivex.schedulers.Schedulers; import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.ui.fragment.AllMissionsFragment; import us.shandian.giga.ui.fragment.MissionsFragment; public class DownloadActivity extends AppCompatActivity { private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag"; - private DeleteDownloadManager mDeleteDownloadManager; @Override protected void onCreate(Bundle savedInstanceState) { @@ -47,32 +43,17 @@ public class DownloadActivity extends AppCompatActivity { actionBar.setDisplayShowTitleEnabled(true); } - mDeleteDownloadManager = new DeleteDownloadManager(this); - mDeleteDownloadManager.restoreState(savedInstanceState); - - MissionsFragment fragment = (MissionsFragment) getFragmentManager().findFragmentByTag(MISSIONS_FRAGMENT_TAG); - if (fragment != null) { - fragment.setDeleteManager(mDeleteDownloadManager); - } else { - getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - updateFragments(); - getWindow().getDecorView().getViewTreeObserver().removeGlobalOnLayoutListener(this); - } - }); - } - } - - @Override - protected void onSaveInstanceState(Bundle outState) { - mDeleteDownloadManager.saveState(outState); - super.onSaveInstanceState(outState); + getWindow().getDecorView().getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + updateFragments(); + getWindow().getDecorView().getViewTreeObserver().removeGlobalOnLayoutListener(this); + } + }); } private void updateFragments() { - MissionsFragment fragment = new AllMissionsFragment(); - fragment.setDeleteManager(mDeleteDownloadManager); + MissionsFragment fragment = new MissionsFragment(); getFragmentManager().beginTransaction() .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) @@ -99,7 +80,6 @@ public class DownloadActivity extends AppCompatActivity { case R.id.action_settings: { Intent intent = new Intent(this, SettingsActivity.class); startActivity(intent); - deletePending(); return true; } default: @@ -108,14 +88,7 @@ public class DownloadActivity extends AppCompatActivity { } @Override - public void onBackPressed() { - super.onBackPressed(); - deletePending(); - } - - private void deletePending() { - Completable.fromAction(mDeleteDownloadManager::deletePending) - .subscribeOn(Schedulers.io()) - .subscribe(); + public void onRestoreInstanceState(Bundle inState){ + super.onRestoreInstanceState(inState); } } 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 9bbda6032..4f98f7f28 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,13 +1,17 @@ package org.schabi.newpipe.download; import android.content.Context; +import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.PreferenceManager; 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.app.AlertDialog; import android.support.v7.widget.Toolbar; import android.util.Log; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -22,38 +26,55 @@ 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.NewPipe; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.extractor.utils.Localization; 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.SecondaryStreamHelper; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import icepick.Icepick; import icepick.State; import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.postprocessing.Postprocessing; 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; - @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; + @State + protected StreamInfo currentInfo; + @State + protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + @State + protected StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + @State + protected int selectedVideoIndex = 0; + @State + protected int selectedAudioIndex = 0; + @State + protected int selectedSubtitleIndex = 0; - private StreamItemAdapter audioStreamsAdapter; - private StreamItemAdapter videoStreamsAdapter; + private StreamItemAdapter audioStreamsAdapter; + private StreamItemAdapter videoStreamsAdapter; + private StreamItemAdapter subtitleStreamsAdapter; private final CompositeDisposable disposables = new CompositeDisposable(); @@ -63,6 +84,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private TextView threadsCountTextView; private SeekBar threadsSeekBar; + private SharedPreferences prefs; + public static DownloadDialog newInstance(StreamInfo info) { DownloadDialog dialog = new DownloadDialog(); dialog.setInfo(info); @@ -78,6 +101,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck instance.setVideoStreams(streamsList); instance.setSelectedVideoStream(selectedStreamIndex); instance.setAudioStreams(info.getAudioStreams()); + instance.setSubtitleStreams(info.getSubtitles()); + return instance; } @@ -86,7 +111,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } public void setAudioStreams(List audioStreams) { - setAudioStreams(new StreamSizeWrapper<>(audioStreams)); + setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); } public void setAudioStreams(StreamSizeWrapper wrappedAudioStreams) { @@ -94,13 +119,21 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } public void setVideoStreams(List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams)); + setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); } public void setVideoStreams(StreamSizeWrapper wrappedVideoStreams) { this.wrappedVideoStreams = wrappedVideoStreams; } + public void setSubtitleStreams(List subtitleStreams) { + setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); + } + + public void setSubtitleStreams(StreamSizeWrapper wrappedSubtitleStreams) { + this.wrappedSubtitleStreams = wrappedSubtitleStreams; + } + public void setSelectedVideoStream(int selectedVideoIndex) { this.selectedVideoIndex = selectedVideoIndex; } @@ -109,6 +142,10 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck this.selectedAudioIndex = selectedAudioIndex; } + public void setSelectedSubtitleStream(int selectedSubtitleIndex) { + this.selectedSubtitleIndex = selectedSubtitleIndex; + } + /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -116,7 +153,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]"); if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { getDialog().dismiss(); return; @@ -125,13 +163,29 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(getContext())); Icepick.restoreInstanceState(this, savedInstanceState); - this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, true); + SparseArray> secondaryStreams = new SparseArray<>(4); + List videoStreams = wrappedVideoStreams.getStreamsList(); + + for (int i = 0; i < videoStreams.size(); i++) { + if (!videoStreams.get(i).isVideoOnly()) continue; + AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); + + if (audioStream != null) { + secondaryStreams.append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); + } else if (DEBUG) { + Log.w(TAG, "No audio stream candidates for video format " + videoStreams.get(i).getFormat().name()); + } + } + + this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, secondaryStreams); this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedSubtitleStreams); } @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); + if (DEBUG) + Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]"); return inflater.inflate(R.layout.download_dialog, container); } @@ -142,6 +196,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck nameEditText.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); + selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); + streamsSpinner = view.findViewById(R.id.quality_spinner); streamsSpinner.setOnItemSelectedListener(this); @@ -154,14 +210,18 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck initToolbar(view.findViewById(R.id.toolbar)); setupDownloadOptions(); - int def = 3; - threadsCountTextView.setText(String.valueOf(def)); - threadsSeekBar.setProgress(def - 1); + prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + + int threads = prefs.getInt(getString(R.string.default_download_threads), 3); + threadsCountTextView.setText(String.valueOf(threads)); + threadsSeekBar.setProgress(threads - 1); threadsSeekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekbar, int progress, boolean fromUser) { - threadsCountTextView.setText(String.valueOf(progress + 1)); + progress++; + prefs.edit().putInt(getString(R.string.default_download_threads), progress).apply(); + threadsCountTextView.setText(String.valueOf(progress)); } @Override @@ -189,6 +249,11 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setupAudioSpinner(); } })); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams).subscribe(result -> { + if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + setupSubtitleSpinner(); + } + })); } @Override @@ -216,7 +281,7 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.okay) { - downloadSelected(); + prepareSelectedDownload(); return true; } return false; @@ -239,13 +304,24 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck setRadioButtonsState(true); } + private void setupSubtitleSpinner() { + if (getContext() == null) return; + + streamsSpinner.setAdapter(subtitleStreamsAdapter); + streamsSpinner.setSelection(selectedSubtitleIndex); + setRadioButtonsState(true); + } + /*////////////////////////////////////////////////////////////////////////// // Radio group Video&Audio options - Listener //////////////////////////////////////////////////////////////////////////*/ @Override public void onCheckedChanged(RadioGroup group, @IdRes int checkedId) { - if (DEBUG) Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); + if (DEBUG) + Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]"); + boolean flag = true; + switch (checkedId) { case R.id.audio_button: setupAudioSpinner(); @@ -253,7 +329,13 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck case R.id.video_button: setupVideoSpinner(); break; + case R.id.subtitle_button: + setupSubtitleSpinner(); + flag = false; + break; } + + threadsSeekBar.setEnabled(flag); } /*////////////////////////////////////////////////////////////////////////// @@ -262,7 +344,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck @Override public void onItemSelected(AdapterView parent, View view, int position, long id) { - if (DEBUG) Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); + if (DEBUG) + Log.d(TAG, "onItemSelected() called with: parent = [" + parent + "], view = [" + view + "], position = [" + position + "], id = [" + id + "]"); switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedAudioIndex = position; @@ -270,6 +353,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck case R.id.video_button: selectedVideoIndex = position; break; + case R.id.subtitle_button: + selectedSubtitleIndex = position; + break; } } @@ -286,11 +372,14 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck final RadioButton audioButton = radioVideoAudioGroup.findViewById(R.id.audio_button); final RadioButton videoButton = radioVideoAudioGroup.findViewById(R.id.video_button); + final RadioButton subtitleButton = radioVideoAudioGroup.findViewById(R.id.subtitle_button); final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; + final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); + subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); if (isVideoStreamsAvailable) { videoButton.setChecked(true); @@ -298,6 +387,9 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } else if (isAudioStreamsAvailable) { audioButton.setChecked(true); setupAudioSpinner(); + } else if (isSubtitleStreamsAvailable) { + subtitleButton.setChecked(true); + setupSubtitleSpinner(); } else { Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.LENGTH_SHORT).show(); getDialog().dismiss(); @@ -307,28 +399,144 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck private void setRadioButtonsState(boolean enabled) { radioVideoAudioGroup.findViewById(R.id.audio_button).setEnabled(enabled); radioVideoAudioGroup.findViewById(R.id.video_button).setEnabled(enabled); + radioVideoAudioGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); } - private void downloadSelected() { - Stream stream; - String location; + private int getSubtitleIndexBy(List streams) { + Localization loc = NewPipe.getPreferredLocalization(); - 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) { - stream = audioStreamsAdapter.getItem(selectedAudioIndex); - location = NewPipeSettings.getAudioDownloadPath(getContext()); - } else { - stream = videoStreamsAdapter.getItem(selectedVideoIndex); - location = NewPipeSettings.getVideoDownloadPath(getContext()); + for (int i = 0; i < streams.size(); i++) { + Locale streamLocale = streams.get(i).getLocale(); + String tag = streamLocale.getLanguage().concat("-").concat(streamLocale.getCountry()); + if (tag.equalsIgnoreCase(loc.getLanguage())) { + return i; + } } - String url = stream.getUrl(); - fileName += "." + stream.getFormat().getSuffix(); + // fallback + // 1st loop match country & language + // 2nd loop match language only + int index = loc.getLanguage().indexOf("-"); + String lang = index > 0 ? loc.getLanguage().substring(0, index) : loc.getLanguage(); + + for (int j = 0; j < 2; j++) { + for (int i = 0; i < streams.size(); i++) { + Locale streamLocale = streams.get(i).getLocale(); + + if (streamLocale.getLanguage().equalsIgnoreCase(lang)) { + if (j > 0 || streamLocale.getCountry().equalsIgnoreCase(loc.getCountry())) { + return i; + } + } + } + } + + return 0; + } + + private void prepareSelectedDownload() { + final Context context = getContext(); + Stream stream; + String location; + char kind; + + String fileName = nameEditText.getText().toString().trim(); + if (fileName.isEmpty()) + fileName = FilenameUtils.createFilename(context, currentInfo.getName()); + + switch (radioVideoAudioGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + stream = audioStreamsAdapter.getItem(selectedAudioIndex); + location = NewPipeSettings.getAudioDownloadPath(context); + kind = 'a'; + break; + case R.id.video_button: + stream = videoStreamsAdapter.getItem(selectedVideoIndex); + location = NewPipeSettings.getVideoDownloadPath(context); + kind = 'v'; + break; + case R.id.subtitle_button: + stream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); + location = NewPipeSettings.getVideoDownloadPath(context);// assume that subtitle & video go together + kind = 's'; + break; + default: + return; + } + + int threads; + + if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + threads = 1;// use unique thread for subtitles due small file size + fileName += ".srt";// final subtitle format + } else { + threads = threadsSeekBar.getProgress() + 1; + fileName += "." + stream.getFormat().getSuffix(); + } + + final String finalFileName = fileName; + + DownloadManagerService.checkForRunningMission(context, location, fileName, (listed, finished) -> { + // should be safe run the following code without "getActivity().runOnUiThread()" + if (listed) { + AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.download_dialog_title) + .setMessage(finished ? R.string.overwrite_warning : R.string.download_already_running) + .setPositiveButton( + finished ? R.string.overwrite : R.string.generate_unique_name, + (dialog, which) -> downloadSelected(context, stream, location, finalFileName, kind, threads) + ) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> { + dialog.cancel(); + }) + .create() + .show(); + } else { + downloadSelected(context, stream, location, finalFileName, kind, threads); + } + }); + } + + private void downloadSelected(Context context, Stream selectedStream, String location, String fileName, char kind, int threads) { + String[] urls; + String psName = null; + String[] psArgs = null; + String secondaryStreamUrl = null; + long nearLength = 0; + + if (selectedStream instanceof VideoStream) { + SecondaryStreamHelper secondaryStream = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + + if (secondaryStream != null) { + secondaryStreamUrl = secondaryStream.getStream().getUrl(); + psName = selectedStream.getFormat() == MediaFormat.MPEG_4 ? Postprocessing.ALGORITHM_MP4_DASH_MUXER : Postprocessing.ALGORITHM_WEBM_MUXER; + psArgs = null; + long videoSize = wrappedVideoStreams.getSizeInBytes((VideoStream) selectedStream); + + // set nearLength, only, if both sizes are fetched or known. this probably does not work on weak internet connections + if (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondaryStream.getSizeInBytes() + videoSize; + } + } + } else if ((selectedStream instanceof SubtitlesStream) && selectedStream.getFormat() == MediaFormat.TTML) { + psName = Postprocessing.ALGORITHM_TTML_CONVERTER; + psArgs = new String[]{ + selectedStream.getFormat().getSuffix(), + "false",// ignore empty frames + "false",// detect youtube duplicate lines + }; + } + + if (secondaryStreamUrl == null) { + urls = new String[]{selectedStream.getUrl()}; + } else { + urls = new String[]{selectedStream.getUrl(), secondaryStreamUrl}; + } + + DownloadManagerService.startMission(context, urls, location, fileName, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); - DownloadManagerService.startMission(getContext(), url, location, fileName, isAudio, threadsSeekBar.getProgress() + 1); getDialog().dismiss(); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java index 2dd7071be..27cc3ec8a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdaptor.java @@ -1,10 +1,9 @@ package org.schabi.newpipe.fragments.detail; -import android.support.annotation.Nullable; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentPagerAdapter; -import android.support.v4.view.PagerAdapter; +import android.view.ViewGroup; import java.util.ArrayList; import java.util.List; @@ -13,10 +12,11 @@ public class TabAdaptor extends FragmentPagerAdapter { private final List mFragmentList = new ArrayList<>(); private final List mFragmentTitleList = new ArrayList<>(); - int baseId = 0; + private final FragmentManager fragmentManager; public TabAdaptor(FragmentManager fm) { super(fm); + this.fragmentManager = fm; } @Override @@ -29,18 +29,6 @@ public class TabAdaptor extends FragmentPagerAdapter { return mFragmentList.size(); } - @Nullable - @Override - public CharSequence getPageTitle(int position) { - return mFragmentTitleList.get(position); - } - - @Override - public long getItemId(int position) { - // give an ID different from position when position has been changed - return baseId + position; - } - public void addFragment(Fragment fragment, String title) { mFragmentList.add(fragment); mFragmentTitleList.add(title); @@ -73,19 +61,13 @@ public class TabAdaptor extends FragmentPagerAdapter { else return POSITION_NONE; } - /** - * Notify that the position of a fragment has been changed. - * Create a new ID for each position to force recreation of the fragment - * @param n number of items which have been changed - */ - public void notifyChangeInPosition(int n) { - // shift the ID returned by getItemId outside the range of all previous fragments - // https://stackoverflow.com/questions/10396321/remove-fragment-page-from-viewpager-in-android - baseId += getCount() + n; - } - public void notifyDataSetUpdate(){ - notifyChangeInPosition(1); notifyDataSetChanged(); } + + @Override + public void destroyItem(ViewGroup container, int position, Object object) { + fragmentManager.beginTransaction().remove((Fragment) object).commitNowAllowingStateLoss(); + } + } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index 76047b725..c007789e5 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 @@ -10,7 +10,6 @@ import android.os.Build; import android.os.Bundle; 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.design.widget.AppBarLayout; @@ -18,7 +17,6 @@ import android.support.design.widget.TabLayout; import android.support.v4.app.Fragment; import android.support.v4.content.ContextCompat; import android.support.v4.view.ViewPager; -import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; @@ -29,7 +27,6 @@ import android.text.method.LinkMovementMethod; import android.text.util.Linkify; import android.util.DisplayMetrics; import android.util.Log; -import android.util.TypedValue; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -39,7 +36,6 @@ import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.FrameLayout; -import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.RelativeLayout; @@ -56,8 +52,6 @@ import org.schabi.newpipe.ReCaptchaActivity; import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; @@ -72,18 +66,16 @@ import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment; import org.schabi.newpipe.fragments.list.videos.RelatedVideosFragment; -import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.MainVideoPlayer; import org.schabi.newpipe.player.PopupVideoPlayer; -import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.old.PlayVideoActivity; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; +import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; @@ -91,11 +83,9 @@ import org.schabi.newpipe.util.InfoCache; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; 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.Collection; @@ -192,6 +182,7 @@ public class VideoDetailFragment private ViewPager viewPager; private TabAdaptor pageAdapter; private TabLayout tabLayout; + private FrameLayout relatedStreamsLayout; /*////////////////////////////////////////////////////////////////////////*/ @@ -383,14 +374,14 @@ public class VideoDetailFragment Log.w(TAG, "Can't open channel because we got no channel URL"); } else { try { - NavigationHelper.openChannelFragment( - getFragmentManager(), - currentInfo.getServiceId(), - currentInfo.getUploaderUrl(), - currentInfo.getUploaderName()); + NavigationHelper.openChannelFragment( + getFragmentManager(), + currentInfo.getServiceId(), + currentInfo.getUploaderUrl(), + currentInfo.getUploaderName()); } catch (Exception e) { ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); - } + } } break; case R.id.detail_thumbnail_root_layout: @@ -489,6 +480,8 @@ public class VideoDetailFragment tabLayout = rootView.findViewById(R.id.tablayout); tabLayout.setupWithViewPager(viewPager); + relatedStreamsLayout = rootView.findViewById(R.id.relatedStreamsLayout); + setHeightThumbnail(); @@ -588,6 +581,7 @@ public class VideoDetailFragment } } + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -676,7 +670,7 @@ public class VideoDetailFragment sortedVideoStreams = ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false); selectedVideoStreamIndex = ListHelper.getDefaultResolutionIndex(activity, sortedVideoStreams); - final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams), isExternalPlayerEnabled); + final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams, activity), isExternalPlayerEnabled); spinnerToolbar.setAdapter(streamsAdapter); spinnerToolbar.setSelection(selectedVideoStreamIndex); spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @@ -771,11 +765,9 @@ public class VideoDetailFragment initTabs(); if (scrollToTop) appBarLayout.setExpanded(true, true); - animateView(contentRootLayoutHiding, - false, 0, 0, () -> { - handleResult(info); - showContentWithAnimation(120, 0, .01f); - }); + handleResult(info); + showContent(); + } protected void prepareAndLoadInfo() { @@ -798,8 +790,8 @@ public class VideoDetailFragment .subscribe((@NonNull StreamInfo result) -> { isLoading.set(false); currentInfo = result; - showContentWithAnimation(120, 0, 0); handleResult(result); + showContent(); }, (@NonNull Throwable throwable) -> { isLoading.set(false); onError(throwable); @@ -814,7 +806,7 @@ public class VideoDetailFragment pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url, name), COMMENTS_TAB_TAG); } - if(showRelatedStreams){ + if(showRelatedStreams && null == relatedStreamsLayout){ //temp empty fragment. will be updated in handleResult pageAdapter.addFragment(new Fragment(), RELATED_TAB_TAG); } @@ -879,7 +871,7 @@ public class VideoDetailFragment .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { startOnExternalPlayer(activity, currentInfo, selectedVideoStream); } else { - openNormalPlayer(selectedVideoStream); + openNormalPlayer(); } } @@ -892,24 +884,13 @@ public class VideoDetailFragment } } - private void openNormalPlayer(VideoStream selectedVideoStream) { + private void openNormalPlayer() { Intent mIntent; - boolean useOldPlayer = PlayerHelper.isUsingOldPlayer(activity) || (Build.VERSION.SDK_INT < 16); - if (!useOldPlayer) { - // ExoPlayer - final PlayQueue playQueue = new SinglePlayQueue(currentInfo); - mIntent = NavigationHelper.getPlayerIntent(activity, - MainVideoPlayer.class, - playQueue, - getSelectedVideoStream().getResolution()); - } else { - // Internal Player - mIntent = new Intent(activity, PlayVideoActivity.class) - .putExtra(PlayVideoActivity.VIDEO_TITLE, currentInfo.getName()) - .putExtra(PlayVideoActivity.STREAM_URL, selectedVideoStream.getUrl()) - .putExtra(PlayVideoActivity.VIDEO_URL, currentInfo.getUrl()) - .putExtra(PlayVideoActivity.START_POSITION, currentInfo.getStartPosition()); - } + final PlayQueue playQueue = new SinglePlayQueue(currentInfo); + mIntent = NavigationHelper.getPlayerIntent(activity, + MainVideoPlayer.class, + playQueue, + getSelectedVideoStream().getResolution()); startActivity(mIntent); } @@ -964,24 +945,6 @@ public class VideoDetailFragment })); } - private View getSeparatorView() { - View separator = new View(activity); - LinearLayout.LayoutParams params = - new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1); - int m8 = (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 8, getResources().getDisplayMetrics()); - int m5 = (int) TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, 5, getResources().getDisplayMetrics()); - params.setMargins(m8, m5, m8, m5); - separator.setLayoutParams(params); - - TypedValue typedValue = new TypedValue(); - activity.getTheme().resolveAttribute(R.attr.separator_color, typedValue, true); - separator.setBackgroundColor(typedValue.data); - - return separator; - } - private void setHeightThumbnail() { final DisplayMetrics metrics = getResources().getDisplayMetrics(); boolean isPortrait = metrics.heightPixels > metrics.widthPixels; @@ -993,36 +956,8 @@ public class VideoDetailFragment thumbnailImageView.setMinimumHeight(height); } - private void showContentWithAnimation(long duration, - long delay, - @FloatRange(from = 0.0f, to = 1.0f) float translationPercent) { - int translationY = (int) (getResources().getDisplayMetrics().heightPixels * - (translationPercent > 0.0f ? translationPercent : .06f)); - - contentRootLayoutHiding.animate().setListener(null).cancel(); - contentRootLayoutHiding.setAlpha(0f); - contentRootLayoutHiding.setTranslationY(translationY); - contentRootLayoutHiding.setVisibility(View.VISIBLE); - contentRootLayoutHiding.animate() - .alpha(1f) - .translationY(0) - .setStartDelay(delay) - .setDuration(duration) - .setInterpolator(new FastOutSlowInInterpolator()) - .start(); - - uploaderRootLayout.animate().setListener(null).cancel(); - uploaderRootLayout.setAlpha(0f); - uploaderRootLayout.setTranslationY(translationY); - uploaderRootLayout.setVisibility(View.VISIBLE); - uploaderRootLayout.animate() - .alpha(1f) - .translationY(0) - .setStartDelay((long) (duration * .5f) + delay) - .setDuration(duration) - .setInterpolator(new FastOutSlowInInterpolator()) - .start(); - + private void showContent() { + AnimationUtils.slideUp(contentRootLayoutHiding,120, 96, 0.06f); } protected void setInitialData(int serviceId, String url, String name) { @@ -1057,7 +992,7 @@ public class VideoDetailFragment public void showLoading() { super.showLoading(); - animateView(contentRootLayoutHiding, false, 200); + contentRootLayoutHiding.setVisibility(View.INVISIBLE); animateView(spinnerToolbar, false, 200); animateView(thumbnailPlayButton, false, 50); animateView(detailDurationView, false, 100); @@ -1071,6 +1006,14 @@ public class VideoDetailFragment videoTitleToggleArrow.setVisibility(View.GONE); videoTitleRoot.setClickable(false); + if(relatedStreamsLayout != null){ + if(showRelatedStreams){ + relatedStreamsLayout.setVisibility(View.INVISIBLE); + }else{ + relatedStreamsLayout.setVisibility(View.GONE); + } + } + imageLoader.cancelDisplayTask(thumbnailImageView); imageLoader.cancelDisplayTask(uploaderThumb); thumbnailImageView.setImageBitmap(null); @@ -1084,8 +1027,15 @@ public class VideoDetailFragment setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName()); if(showRelatedStreams){ - pageAdapter.updateItem(RELATED_TAB_TAG, RelatedVideosFragment.getInstance(currentInfo)); - pageAdapter.notifyDataSetUpdate(); + if(null == relatedStreamsLayout){ //phone + pageAdapter.updateItem(RELATED_TAB_TAG, RelatedVideosFragment.getInstance(currentInfo)); + pageAdapter.notifyDataSetUpdate(); + }else{ //tablet + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedStreamsLayout, RelatedVideosFragment.getInstance(currentInfo)) + .commitNow(); + relatedStreamsLayout.setVisibility(View.VISIBLE); + } } pushToStack(serviceId, url, name); @@ -1149,10 +1099,10 @@ public class VideoDetailFragment detailDurationView.setVisibility(View.GONE); } + videoDescriptionView.setVisibility(View.GONE); videoTitleRoot.setClickable(true); videoTitleToggleArrow.setVisibility(View.VISIBLE); videoTitleToggleArrow.setImageResource(R.drawable.arrow_down); - videoDescriptionView.setVisibility(View.GONE); videoDescriptionRootLayout.setVisibility(View.GONE); if (!TextUtils.isEmpty(info.getUploadDate())) { videoUploadDateView.setText(Localization.localizeDate(activity, info.getUploadDate())); @@ -1205,6 +1155,7 @@ public class VideoDetailFragment downloadDialog.setVideoStreams(sortedVideoStreams); downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); + downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (Exception e) { @@ -1253,4 +1204,4 @@ public class VideoDetailFragment showError(getString(R.string.blocked_by_gema), false, R.drawable.gruese_die_gema); } -} \ No newline at end of file +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index cd557c931..b61fe0d02 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -3,10 +3,15 @@ package org.schabi.newpipe.fragments.list; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.v7.app.ActionBar; import android.support.v7.app.AppCompatActivity; +import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; @@ -22,9 +27,9 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; +import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.NavigationHelper; @@ -37,7 +42,7 @@ import java.util.Queue; import static org.schabi.newpipe.util.AnimationUtils.animateView; -public abstract class BaseListFragment extends BaseStateFragment implements ListViewContract, StateSaver.WriteRead { +public abstract class BaseListFragment extends BaseStateFragment implements ListViewContract, StateSaver.WriteRead, SharedPreferences.OnSharedPreferenceChangeListener { /*////////////////////////////////////////////////////////////////////////// // Views @@ -45,6 +50,9 @@ public abstract class BaseListFragment extends BaseStateFragment implem protected InfoListAdapter infoListAdapter; protected RecyclerView itemsList; + private int updateFlags = 0; + + private static final int LIST_MODE_UPDATE_FLAG = 0x32; /*////////////////////////////////////////////////////////////////////////// // LifeCycle @@ -60,12 +68,31 @@ public abstract class BaseListFragment extends BaseStateFragment implem public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(this); } @Override public void onDestroy() { super.onDestroy(); StateSaver.onDestroy(savedState); + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onResume() { + super.onResume(); + + if (updateFlags != 0) { + if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { + final boolean useGrid = isGridLayout(); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + infoListAdapter.setGridItemVariants(useGrid); + infoListAdapter.notifyDataSetChanged(); + } + updateFlags = 0; + } } /*////////////////////////////////////////////////////////////////////////// @@ -120,13 +147,25 @@ public abstract class BaseListFragment extends BaseStateFragment implem return new LinearLayoutManager(activity); } + protected RecyclerView.LayoutManager getGridLayoutManager() { + final Resources resources = activity.getResources(); + int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); + width += (24 * resources.getDisplayMetrics().density); + final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width); + final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); + lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); + return lm; + } + @Override protected void initViews(View rootView, Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); + final boolean useGrid = isGridLayout(); itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(getListLayoutManager()); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + infoListAdapter.setGridItemVariants(useGrid); infoListAdapter.setFooter(getListFooter()); infoListAdapter.setHeader(getListHeader()); @@ -185,7 +224,7 @@ public abstract class BaseListFragment extends BaseStateFragment implem infoListAdapter.setOnCommentsSelectedListener(new OnClickGesture() { @Override public void selected(CommentsInfoItem selectedItem) { - //Log.d("comments" , "this comment was clicked" + selectedItem.getCommentText()); + onItemSelected(selectedItem); } }); @@ -316,4 +355,22 @@ public abstract class BaseListFragment extends BaseStateFragment implem public void handleNextItems(N result) { isLoading.set(false); } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(getString(R.string.list_view_mode_key))) { + updateFlags |= LIST_MODE_UPDATE_FLAG; + } + } + + protected boolean isGridLayout() { + final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)); + if ("auto".equals(list_mode)) { + final Configuration configuration = getResources().getConfiguration(); + return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); + } else { + return "grid".equals(list_mode); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java index e7778c905..956e6c1c8 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java @@ -1,72 +1,26 @@ package org.schabi.newpipe.fragments.list.comments; -import android.app.Activity; import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; -import android.support.v4.content.ContextCompat; -import android.support.v7.app.ActionBar; -import android.text.TextUtils; -import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.jakewharton.rxbinding2.view.RxView; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -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.comments.CommentsInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.InfoItemDialog; -import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; -import org.schabi.newpipe.local.subscription.SubscriptionService; -import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import io.reactivex.Observable; import io.reactivex.Single; -import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; -import io.reactivex.disposables.Disposable; -import io.reactivex.functions.Action; -import io.reactivex.functions.Consumer; -import io.reactivex.functions.Function; -import io.reactivex.schedulers.Schedulers; - -import static org.schabi.newpipe.util.AnimationUtils.animateBackgroundColor; -import static org.schabi.newpipe.util.AnimationUtils.animateTextColor; -import static org.schabi.newpipe.util.AnimationUtils.animateView; public class CommentsFragment extends BaseListInfoFragment { @@ -139,6 +93,8 @@ public class CommentsFragment extends BaseListInfoFragment { public void handleResult(@NonNull CommentsInfo result) { super.handleResult(result); + AnimationUtils.slideUp(getView(),120, 96, 0.06f); + if (!result.getErrors().isEmpty()) { showSnackBarError(result.getErrors(), UserAction.REQUESTED_COMMENTS, NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); } @@ -167,6 +123,7 @@ public class CommentsFragment extends BaseListInfoFragment { protected boolean onError(Throwable exception) { if (super.onError(exception)) return true; + hideLoading(); showSnackBarError(exception, UserAction.REQUESTED_COMMENTS, NewPipe.getNameOfService(serviceId), url, R.string.error_unable_to_load_comments); return true; } @@ -184,4 +141,9 @@ public class CommentsFragment extends BaseListInfoFragment { public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { return; } + + @Override + protected boolean isGridLayout() { + return false; + } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index 92138f7db..7d4500691 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -128,26 +128,16 @@ public class KioskFragment extends BaseListInfoFragment { @Override public Single loadResult(boolean forceReload) { - String contentCountry = PreferenceManager - .getDefaultSharedPreferences(activity) - .getString(getString(R.string.content_country_key), - getString(R.string.default_country_value)); return ExtractorHelper.getKioskInfo(serviceId, url, - contentCountry, forceReload); } @Override public Single loadMoreItemsLogic() { - String contentCountry = PreferenceManager - .getDefaultSharedPreferences(activity) - .getString(getString(R.string.content_country_key), - getString(R.string.default_country_value)); return ExtractorHelper.getMoreKioskItems(serviceId, url, - currentNextPageUrl, - contentCountry); + currentNextPageUrl); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 19c7d463e..2833abb8d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -626,7 +626,7 @@ public class SearchFragment } final Observable> network = ExtractorHelper - .suggestionsFor(serviceId, query, contentCountry) + .suggestionsFor(serviceId, query) .toObservable() .map(strings -> { List result = new ArrayList<>(); @@ -726,8 +726,7 @@ public class SearchFragment searchDisposable = ExtractorHelper.searchFor(serviceId, searchString, Arrays.asList(contentFilter), - sortFilter, - contentCountry) + sortFilter) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((searchResult, throwable) -> isLoading.set(false)) @@ -745,8 +744,7 @@ public class SearchFragment searchString, asList(contentFilter), sortFilter, - nextPageUrl, - contentCountry) + nextPageUrl) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java index 08a6a3bc3..c8fc2197a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java @@ -1,7 +1,9 @@ package org.schabi.newpipe.fragments.list.videos; import android.content.Context; +import android.content.SharedPreferences; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.view.LayoutInflater; @@ -9,36 +11,32 @@ import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.Switch; import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; -import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.comments.CommentsInfo; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.report.UserAction; -import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.AnimationUtils; import org.schabi.newpipe.util.RelatedStreamInfo; -import java.util.List; +import java.io.Serializable; import io.reactivex.Single; import io.reactivex.disposables.CompositeDisposable; -public class RelatedVideosFragment extends BaseListInfoFragment { +public class RelatedVideosFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener{ private CompositeDisposable disposables = new CompositeDisposable(); private RelatedStreamInfo relatedStreamInfo; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ - - + private View headerRootLayout; + private Switch aSwitch; private boolean mIsVisibleToUser = false; @@ -74,6 +72,28 @@ public class RelatedVideosFragment extends BaseListInfoFragment loadMoreItemsLogic() { return Single.fromCallable(() -> ListExtractor.InfoItemsPage.emptyPage()); @@ -91,12 +111,17 @@ public class RelatedVideosFragment extends BaseListInfoFragment infoItemList; private boolean useMiniVariant = false; + private boolean useGridVariant = false; private boolean showFooter = false; private View header = null; private View footer = null; @@ -103,6 +111,10 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { if (data != null) { if (DEBUG) { @@ -215,11 +227,11 @@ public class InfoListAdapter extends RecyclerView.Adapter { + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener().held(item); + } + return true; + }); } protected String getDetailLine(final ChannelInfoItem item) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java index 046cadc3f..c2bc86691 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.info_list.holder; import android.support.v7.app.AppCompatActivity; import android.text.TextUtils; -import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; @@ -13,7 +12,6 @@ import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import de.hdodenhof.circleimageview.CircleImageView; @@ -23,6 +21,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { private final TextView itemContentView; private final TextView itemLikesCountView; private final TextView itemDislikesCountView; + private final TextView itemPublishedTime; private static final int commentDefaultLines = 2; private static final int commentExpandedLines = 1000; @@ -31,9 +30,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemContentView = itemView.findViewById(R.id.itemCommentContentView); itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); itemDislikesCountView = itemView.findViewById(R.id.detail_thumbs_down_count_view); + itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime); + itemContentView = itemView.findViewById(R.id.itemCommentContentView); } public CommentsMiniInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { @@ -66,10 +66,17 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { } }); + // ellipsize if not already ellipsized + if (null == itemContentView.getEllipsize()) { + itemContentView.setEllipsize(TextUtils.TruncateAt.END); + itemContentView.setMaxLines(commentDefaultLines); + } + itemContentView.setText(item.getCommentText()); if (null != item.getLikeCount()) { itemLikesCountView.setText(String.valueOf(item.getLikeCount())); } + itemPublishedTime.setText(item.getPublishedTime()); itemView.setOnClickListener(view -> { toggleEllipsize(item.getCommentText()); diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java new file mode 100644 index 000000000..96b9c90a7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.info_list.InfoItemBuilder; + +public class PlaylistGridInfoItemHolder extends PlaylistMiniInfoItemHolder { + + public PlaylistGridInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); + } +} \ No newline at end of file diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java new file mode 100644 index 000000000..a2e585857 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.info_list.InfoItemBuilder; + +public class StreamGridInfoItemHolder extends StreamMiniInfoItemHolder { + + public StreamGridInfoItemHolder(InfoItemBuilder infoItemBuilder, ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java index 5192aa2ab..abdf82353 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -1,8 +1,13 @@ package org.schabi.newpipe.local; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; import android.os.Bundle; +import android.preference.PreferenceManager; import android.support.v4.app.Fragment; import android.support.v7.app.ActionBar; +import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; @@ -25,7 +30,7 @@ import static org.schabi.newpipe.util.AnimationUtils.animateView; * called and is memory efficient when in backstack. * */ public abstract class BaseLocalListFragment extends BaseStateFragment - implements ListViewContract { + implements ListViewContract, SharedPreferences.OnSharedPreferenceChangeListener { /*////////////////////////////////////////////////////////////////////////// // Views @@ -36,6 +41,9 @@ public abstract class BaseLocalListFragment extends BaseStateFragment protected LocalItemListAdapter itemListAdapter; protected RecyclerView itemsList; + private int updateFlags = 0; + + private static final int LIST_MODE_UPDATE_FLAG = 0x32; /*////////////////////////////////////////////////////////////////////////// // Lifecycle - Creation @@ -45,6 +53,29 @@ public abstract class BaseLocalListFragment extends BaseStateFragment public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onResume() { + super.onResume(); + if (updateFlags != 0) { + if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { + final boolean useGrid = isGridLayout(); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + itemListAdapter.setGridItemVariants(useGrid); + itemListAdapter.notifyDataSetChanged(); + } + updateFlags = 0; + } } /*////////////////////////////////////////////////////////////////////////// @@ -59,6 +90,16 @@ public abstract class BaseLocalListFragment extends BaseStateFragment return activity.getLayoutInflater().inflate(R.layout.pignate_footer, itemsList, false); } + protected RecyclerView.LayoutManager getGridLayoutManager() { + final Resources resources = activity.getResources(); + int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); + width += (24 * resources.getDisplayMetrics().density); + final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels / (double)width); + final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); + lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount)); + return lm; + } + protected RecyclerView.LayoutManager getListLayoutManager() { return new LinearLayoutManager(activity); } @@ -67,10 +108,13 @@ public abstract class BaseLocalListFragment extends BaseStateFragment protected void initViews(View rootView, Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(getListLayoutManager()); - itemListAdapter = new LocalItemListAdapter(activity); + + final boolean useGrid = isGridLayout(); + itemsList = rootView.findViewById(R.id.items_list); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + + itemListAdapter.setGridItemVariants(useGrid); itemListAdapter.setHeader(headerRootView = getListHeader()); itemListAdapter.setFooter(footerRootView = getListFooter()); @@ -174,4 +218,22 @@ public abstract class BaseLocalListFragment extends BaseStateFragment resetFragment(); return super.onError(exception); } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + if (key.equals(getString(R.string.list_view_mode_key))) { + updateFlags |= LIST_MODE_UPDATE_FLAG; + } + } + + protected boolean isGridLayout() { + final String list_mode = PreferenceManager.getDefaultSharedPreferences(activity).getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)); + if ("auto".equals(list_mode)) { + final Configuration configuration = getResources().getConfiguration(); + return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); + } else { + return "grid".equals(list_mode); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java index 99937b58c..e298dedd3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -1,18 +1,21 @@ package org.schabi.newpipe.local; import android.app.Activity; +import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.local.HeaderFooterHolder; -import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.holder.LocalItemHolder; +import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; +import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; +import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; +import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; import org.schabi.newpipe.util.FallbackViewHolder; import org.schabi.newpipe.util.Localization; @@ -52,14 +55,19 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems; private final DateFormat dateFormat; private boolean showFooter = false; + private boolean useGridVariant = false; private View header = null; private View footer = null; @@ -134,6 +142,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter> { +public class SubscriptionFragment extends BaseStateFragment> implements SharedPreferences.OnSharedPreferenceChangeListener { private static final int REQUEST_EXPORT_CODE = 666; private static final int REQUEST_IMPORT_CODE = 667; @@ -78,6 +89,9 @@ public class SubscriptionFragment extends BaseStateFragment() { - @Override + public void selected(ChannelInfoItem selectedItem) { final FragmentManager fragmentManager = getFM(); NavigationHelper.openChannelFragment(fragmentManager, @@ -326,6 +369,11 @@ public class SubscriptionFragment extends BaseStateFragment importExportOptions.switchState()); } + private void showLongTapDialog(ChannelInfoItem selectedItem) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || getActivity() == null) return; + + final String[] commands = new String[]{ + context.getResources().getString(R.string.share), + context.getResources().getString(R.string.unsubscribe) + }; + + final DialogInterface.OnClickListener actions = (dialogInterface, i) -> { + switch (i) { + case 0: + shareChannel(selectedItem); + break; + case 1: + deleteChannel(selectedItem); + break; + default: + break; + } + }; + + final View bannerView = View.inflate(activity, R.layout.dialog_title, null); + bannerView.setSelected(true); + + TextView titleView = bannerView.findViewById(R.id.itemTitleView); + titleView.setText(selectedItem.getName()); + + TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); + detailsView.setVisibility(View.GONE); + + new AlertDialog.Builder(activity) + .setCustomTitle(bannerView) + .setItems(commands, actions) + .create() + .show(); + + } + + private void shareChannel (ChannelInfoItem selectedItem) { + shareUrl(selectedItem.getName(), selectedItem.getUrl()); + } + + @SuppressLint("CheckResult") + private void deleteChannel (ChannelInfoItem selectedItem) { + subscriptionService.subscriptionTable() + .getSubscription(selectedItem.getServiceId(), selectedItem.getUrl()) + .toObservable() + .observeOn(Schedulers.io()) + .subscribe(getDeleteObserver()); + + Toast.makeText(activity, getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show(); + } + + + + private Observer> getDeleteObserver(){ + return new Observer>() { + @Override + public void onSubscribe(Disposable d) { + disposables.add(d); + } + + @Override + public void onNext(List subscriptionEntities) { + subscriptionService.subscriptionTable().delete(subscriptionEntities); + } + + @Override + public void onError(Throwable exception) { + SubscriptionFragment.this.onError(exception); + } + + @Override + public void onComplete() { } + }; + } + private void resetFragment() { if (disposables != null) disposables.clear(); if (infoListAdapter != null) infoListAdapter.clearStreamItemList(); @@ -445,4 +572,22 @@ public class SubscriptionFragment extends BaseStateFragment 0) { playQueue.setIndex(sizeBeforeAppend); } @@ -296,7 +322,6 @@ public abstract class BasePlayer implements databaseUpdateReactor.clear(); progressUpdateReactor.set(null); - simpleExoPlayer = null; } /*////////////////////////////////////////////////////////////////////////// @@ -424,13 +449,15 @@ public abstract class BasePlayer implements if (!isProgressLoopRunning()) startProgressLoop(); } - public void onBuffering() {} + public void onBuffering() { + } public void onPaused() { if (isProgressLoopRunning()) stopProgressLoop(); } - public void onPausedSeek() {} + public void onPausedSeek() { + } public void onCompleted() { if (DEBUG) Log.d(TAG, "onCompleted() called"); @@ -601,19 +628,19 @@ public abstract class BasePlayer implements /** * Processes the exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. * There are multiple types of errors:

- * + *

* {@link ExoPlaybackException#TYPE_SOURCE TYPE_SOURCE}:

- * + *

* {@link ExoPlaybackException#TYPE_UNEXPECTED TYPE_UNEXPECTED}:

* If a runtime error occurred, then we can try to recover it by restarting the playback * after setting the timestamp recovery.

- * + *

* {@link ExoPlaybackException#TYPE_RENDERER TYPE_RENDERER}:

* If the renderer failed, treat the error as unrecoverable. * * @see #processSourceError(IOException) * @see Player.EventListener#onPlayerError(ExoPlaybackException) - * */ + */ @Override public void onPlayerError(ExoPlaybackException error) { if (DEBUG) Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + @@ -899,8 +926,8 @@ public abstract class BasePlayer implements if (DEBUG) Log.d(TAG, "onPlayPrevious() called"); /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT_MILLIS milliseconds, - * restart current track. Also restart the track if the current track - * is the first in a queue.*/ + * restart current track. Also restart the track if the current track + * is the first in a queue.*/ if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS || playQueue.getIndex() == 0) { seekToDefault(); @@ -1009,8 +1036,8 @@ public abstract class BasePlayer implements try { metadata = (MediaSourceTag) simpleExoPlayer.getCurrentTag(); } catch (IndexOutOfBoundsException | ClassCastException error) { - if(DEBUG) Log.d(TAG, "Could not update metadata: " + error.getMessage()); - if(DEBUG) error.printStackTrace(); + if (DEBUG) Log.d(TAG, "Could not update metadata: " + error.getMessage()); + if (DEBUG) error.printStackTrace(); return; } @@ -1074,7 +1101,9 @@ public abstract class BasePlayer implements currentThumbnail; } - /** Checks if the current playback is a livestream AND is playing at or beyond the live edge */ + /** + * Checks if the current playback is a livestream AND is playing at or beyond the live edge + */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean isLiveEdge() { if (simpleExoPlayer == null || !isLive()) return false; @@ -1098,13 +1127,14 @@ public abstract class BasePlayer implements } catch (@NonNull IndexOutOfBoundsException ignored) { // Why would this even happen =( // But lets log it anyway. Save is save - if(DEBUG) Log.d(TAG, "Could not update metadata: " + ignored.getMessage()); - if(DEBUG) ignored.printStackTrace(); + if (DEBUG) Log.d(TAG, "Could not update metadata: " + ignored.getMessage()); + if (DEBUG) ignored.printStackTrace(); return false; } } public boolean isPlaying() { + if (simpleExoPlayer == null) return false; final int state = simpleExoPlayer.getPlaybackState(); return (state == Player.STATE_READY || state == Player.STATE_BUFFERING) && simpleExoPlayer.getPlayWhenReady(); @@ -1112,7 +1142,9 @@ public abstract class BasePlayer implements @Player.RepeatMode public int getRepeatMode() { - return simpleExoPlayer == null ? Player.REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); + return simpleExoPlayer == null + ? Player.REPEAT_MODE_OFF + : simpleExoPlayer.getRepeatMode(); } public void setRepeatMode(@Player.RepeatMode final int repeatMode) { @@ -1178,4 +1210,8 @@ public abstract class BasePlayer implements if (DEBUG) Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); playQueue.setRecovery(queuePos, windowPos); } + + public boolean gotDestroyed() { + return simpleExoPlayer == null; + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java index 4e8398ff2..f4fea5165 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainVideoPlayer.java @@ -175,6 +175,10 @@ public final class MainVideoPlayer extends AppCompatActivity setLandscape(lastOrientationWasLandscape); } + final int lastResizeMode = defaultPreferences.getInt( + getString(R.string.last_resize_mode), AspectRatioFrameLayout.RESIZE_MODE_FIT); + playerImpl.setResizeMode(lastResizeMode); + // Upon going in or out of multiwindow mode, isInMultiWindow will always be false, // since the first onResume needs to restore the player. // Subsequent onResume calls while multiwindow mode remains the same and the player is @@ -213,10 +217,9 @@ public final class MainVideoPlayer extends AppCompatActivity if (playerImpl == null) return; playerImpl.setRecovery(); - playerState = new PlayerState(playerImpl.getPlayQueue(), playerImpl.getRepeatMode(), - playerImpl.getPlaybackSpeed(), playerImpl.getPlaybackPitch(), - playerImpl.getPlaybackQuality(), playerImpl.getPlaybackSkipSilence(), - playerImpl.isPlaying()); + if(!playerImpl.gotDestroyed()) { + playerState = createPlayerState(); + } StateSaver.tryToSave(isChangingConfigurations(), null, outState, this); } @@ -231,6 +234,7 @@ public final class MainVideoPlayer extends AppCompatActivity if (!isBackPressed) { playerImpl.minimize(); } + playerState = createPlayerState(); playerImpl.destroy(); isInMultiWindow = false; @@ -241,6 +245,13 @@ public final class MainVideoPlayer extends AppCompatActivity // State Saving //////////////////////////////////////////////////////////////////////////*/ + private PlayerState createPlayerState() { + return new PlayerState(playerImpl.getPlayQueue(), playerImpl.getRepeatMode(), + playerImpl.getPlaybackSpeed(), playerImpl.getPlaybackPitch(), + playerImpl.getPlaybackQuality(), playerImpl.getPlaybackSkipSilence(), + playerImpl.isPlaying()); + } + @Override public String generateSuffix() { return "." + UUID.randomUUID().toString() + ".player"; @@ -705,14 +716,27 @@ public final class MainVideoPlayer extends AppCompatActivity @Override protected int nextResizeMode(int currentResizeMode) { + final int newResizeMode; switch (currentResizeMode) { case AspectRatioFrameLayout.RESIZE_MODE_FIT: - return AspectRatioFrameLayout.RESIZE_MODE_FILL; + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; + break; case AspectRatioFrameLayout.RESIZE_MODE_FILL: - return AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + break; default: - return AspectRatioFrameLayout.RESIZE_MODE_FIT; + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + break; } + + storeResizeMode(newResizeMode); + return newResizeMode; + } + + private void storeResizeMode(@AspectRatioFrameLayout.ResizeMode int resizeMode) { + defaultPreferences.edit() + .putInt(getString(R.string.last_resize_mode), resizeMode) + .apply(); } @Override @@ -876,6 +900,11 @@ public final class MainVideoPlayer extends AppCompatActivity public void onMove(int sourceIndex, int targetIndex) { if (playQueue != null) playQueue.move(sourceIndex, targetIndex); } + + @Override + public void onSwiped(int index) { + if(index != -1) playQueue.remove(index); + } }; } @@ -989,12 +1018,14 @@ public final class MainVideoPlayer extends AppCompatActivity private static final int MOVEMENT_THRESHOLD = 40; - private final boolean isPlayerGestureEnabled = PlayerHelper.isPlayerGestureEnabled(getApplicationContext()); + private final boolean isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(getApplicationContext()); + private final boolean isBrightnessGestureEnabled = PlayerHelper.isBrightnessGestureEnabled(getApplicationContext()); + private final int maxVolume = playerImpl.getAudioReactor().getMaxVolume(); @Override public boolean onScroll(MotionEvent initialEvent, MotionEvent movingEvent, float distanceX, float distanceY) { - if (!isPlayerGestureEnabled) return false; + if (!isVolumeGestureEnabled && !isBrightnessGestureEnabled) return false; //noinspection PointlessBooleanExpression if (DEBUG && false) Log.d(TAG, "MainVideoPlayer.onScroll = " + @@ -1010,7 +1041,11 @@ public final class MainVideoPlayer extends AppCompatActivity isMoving = true; - if (initialEvent.getX() > playerImpl.getRootView().getWidth() / 2) { + boolean acceptAnyArea = isVolumeGestureEnabled != isBrightnessGestureEnabled; + boolean acceptVolumeArea = acceptAnyArea || initialEvent.getX() > playerImpl.getRootView().getWidth() / 2; + boolean acceptBrightnessArea = acceptAnyArea || !acceptVolumeArea; + + if (isVolumeGestureEnabled && acceptVolumeArea) { playerImpl.getVolumeProgressBar().incrementProgressBy((int) distanceY); float currentProgressPercent = (float) playerImpl.getVolumeProgressBar().getProgress() / playerImpl.getMaxGestureLength(); @@ -1035,7 +1070,7 @@ public final class MainVideoPlayer extends AppCompatActivity if (playerImpl.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) { playerImpl.getBrightnessRelativeLayout().setVisibility(View.GONE); } - } else { + } else if (isBrightnessGestureEnabled && acceptBrightnessArea) { playerImpl.getBrightnessProgressBar().incrementProgressBy((int) distanceY); float currentProgressPercent = (float) playerImpl.getBrightnessProgressBar().getProgress() / playerImpl.getMaxGestureLength(); diff --git a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java index a36a0576c..f5c731ed9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/PopupVideoPlayer.java @@ -68,7 +68,6 @@ import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.old.PlayVideoActivity; import org.schabi.newpipe.player.resolver.MediaSourceTag; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.util.ListHelper; @@ -80,7 +79,6 @@ import java.util.List; import static org.schabi.newpipe.player.BasePlayer.STATE_PLAYING; import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; import static org.schabi.newpipe.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; -import static org.schabi.newpipe.player.helper.PlayerHelper.isUsingOldPlayer; import static org.schabi.newpipe.util.AnimationUtils.animateView; /** @@ -554,27 +552,17 @@ public final class PopupVideoPlayer extends Service { if (DEBUG) Log.d(TAG, "onFullScreenButtonClicked() called"); setRecovery(); - Intent intent; - if (!isUsingOldPlayer(getApplicationContext())) { - intent = NavigationHelper.getPlayerIntent( - context, - MainVideoPlayer.class, - this.getPlayQueue(), - this.getRepeatMode(), - this.getPlaybackSpeed(), - this.getPlaybackPitch(), - this.getPlaybackSkipSilence(), - this.getPlaybackQuality() - ); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - } else { - intent = new Intent(PopupVideoPlayer.this, PlayVideoActivity.class) - .putExtra(PlayVideoActivity.VIDEO_TITLE, getVideoTitle()) - .putExtra(PlayVideoActivity.STREAM_URL, getSelectedVideoStream().getUrl()) - .putExtra(PlayVideoActivity.VIDEO_URL, getVideoUrl()) - .putExtra(PlayVideoActivity.START_POSITION, Math.round(getPlayer().getCurrentPosition() / 1000f)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - } + final Intent intent = NavigationHelper.getPlayerIntent( + context, + MainVideoPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackSkipSilence(), + this.getPlaybackQuality() + ); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); closePopup(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java index 94305e6c4..2ec4275fc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/ServicePlayerActivity.java @@ -375,6 +375,11 @@ public abstract class ServicePlayerActivity extends AppCompatActivity public void onMove(int sourceIndex, int targetIndex) { if (player != null) player.getPlayQueue().move(sourceIndex, targetIndex); } + + @Override + public void onSwiped(int index) { + if (index != -1) player.getPlayQueue().remove(index); + } }; } 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 8e7db0dae..d30d9b8be 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -683,12 +683,17 @@ public abstract class VideoPlayer extends BasePlayer if (getAspectRatioFrameLayout() != null) { final int currentResizeMode = getAspectRatioFrameLayout().getResizeMode(); final int newResizeMode = nextResizeMode(currentResizeMode); - getAspectRatioFrameLayout().setResizeMode(newResizeMode); - getResizeView().setText(PlayerHelper.resizeTypeOf(context, newResizeMode)); + setResizeMode(newResizeMode); } } + protected void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { + getAspectRatioFrameLayout().setResizeMode(resizeMode); + getResizeView().setText(PlayerHelper.resizeTypeOf(context, resizeMode)); + } + protected abstract int nextResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode); + /*////////////////////////////////////////////////////////////////////////// // SeekBar Listener //////////////////////////////////////////////////////////////////////////*/ diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 05afe2859..e1960247e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -19,11 +19,11 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.Subtitles; +import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.SubtitlesFormat; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -45,7 +45,9 @@ import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MOD import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; import static java.lang.annotation.RetentionPolicy.SOURCE; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.*; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; public class PlayerHelper { private PlayerHelper() {} @@ -87,7 +89,7 @@ public class PlayerHelper { return pitchFormatter.format(pitch); } - public static String mimeTypesOf(final SubtitlesFormat format) { + public static String subtitleMimeTypesOf(final MediaFormat format) { switch (format) { case VTT: return MimeTypes.TEXT_VTT; case TTML: return MimeTypes.APPLICATION_TTML; @@ -97,8 +99,8 @@ public class PlayerHelper { @NonNull public static String captionLanguageOf(@NonNull final Context context, - @NonNull final Subtitles subtitles) { - final String displayName = subtitles.getLocale().getDisplayName(subtitles.getLocale()); + @NonNull final SubtitlesStream subtitles) { + final String displayName = subtitles.getDisplayLanguageName(); return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated)+ ")" : ""); } @@ -145,7 +147,7 @@ public class PlayerHelper { final StreamInfoItem nextVideo = info.getNextVideo(); if (nextVideo != null && !urls.contains(nextVideo.getUrl())) { - return new SinglePlayQueue(nextVideo); + return getAutoQueuedSinglePlayQueue(nextVideo); } final List relatedItems = info.getRelatedStreams(); @@ -158,7 +160,7 @@ public class PlayerHelper { } } Collections.shuffle(autoQueueItems); - return autoQueueItems.isEmpty() ? null : new SinglePlayQueue(autoQueueItems.get(0)); + return autoQueueItems.isEmpty() ? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)); } //////////////////////////////////////////////////////////////////////////// @@ -169,12 +171,12 @@ public class PlayerHelper { return isResumeAfterAudioFocusGain(context, false); } - public static boolean isPlayerGestureEnabled(@NonNull final Context context) { - return isPlayerGestureEnabled(context, true); + public static boolean isVolumeGestureEnabled(@NonNull final Context context) { + return isVolumeGestureEnabled(context, true); } - public static boolean isUsingOldPlayer(@NonNull final Context context) { - return isUsingOldPlayer(context, false); + public static boolean isBrightnessGestureEnabled(@NonNull final Context context) { + return isBrightnessGestureEnabled(context, true); } public static boolean isRememberingPopupDimensions(@NonNull final Context context) { @@ -306,12 +308,12 @@ public class PlayerHelper { return getPreferences(context).getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), b); } - private static boolean isPlayerGestureEnabled(@NonNull final Context context, final boolean b) { - return getPreferences(context).getBoolean(context.getString(R.string.player_gesture_controls_key), b); + private static boolean isVolumeGestureEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.volume_gesture_control_key), b); } - private static boolean isUsingOldPlayer(@NonNull final Context context, final boolean b) { - return getPreferences(context).getBoolean(context.getString(R.string.use_old_player_key), b); + private static boolean isBrightnessGestureEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.brightness_gesture_control_key), b); } private static boolean isRememberingPopupDimensions(@NonNull final Context context, final boolean b) { @@ -350,4 +352,10 @@ public class PlayerHelper { return getPreferences(context).getString(context.getString(R.string.minimize_on_exit_key), key); } + + private static SinglePlayQueue getAutoQueuedSinglePlayQueue(StreamInfoItem streamInfoItem) { + SinglePlayQueue singlePlayQueue = new SinglePlayQueue(streamInfoItem); + singlePlayQueue.getItem().setAutoQueued(true); + return singlePlayQueue; + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/old/PlayVideoActivity.java b/app/src/main/java/org/schabi/newpipe/player/old/PlayVideoActivity.java deleted file mode 100644 index 092f82aad..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/old/PlayVideoActivity.java +++ /dev/null @@ -1,369 +0,0 @@ -package org.schabi.newpipe.player.old; - -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.ActivityInfo; -import android.content.res.Configuration; -import android.media.AudioManager; -import android.media.MediaPlayer; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.support.v7.app.ActionBar; -import android.support.v7.app.AppCompatActivity; -import android.util.DisplayMetrics; -import android.util.Log; -import android.view.Display; -import android.view.KeyEvent; -import android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.WindowManager; -import android.widget.Button; -import android.widget.MediaController; -import android.widget.ProgressBar; -import android.widget.VideoView; - -import org.schabi.newpipe.R; - -/* - * Copyright (C) Christian Schabesberger 2015 - * PlayVideoActivity.java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -public class PlayVideoActivity extends AppCompatActivity { - - //// TODO: 11.09.15 add "choose stream" menu - - private static final String TAG = PlayVideoActivity.class.toString(); - public static final String VIDEO_URL = "video_url"; - public static final String STREAM_URL = "stream_url"; - public static final String VIDEO_TITLE = "video_title"; - private static final String POSITION = "position"; - public static final String START_POSITION = "start_position"; - - private static final long HIDING_DELAY = 3000; - - private String videoUrl = ""; - - private ActionBar actionBar; - private VideoView videoView; - private int position; - private MediaController mediaController; - private ProgressBar progressBar; - private View decorView; - private boolean uiIsHidden; - private static long lastUiShowTime; - private boolean isLandscape = true; - private boolean hasSoftKeys; - - private SharedPreferences prefs; - private static final String PREF_IS_LANDSCAPE = "is_landscape"; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - setContentView(R.layout.activity_play_video); - setVolumeControlStream(AudioManager.STREAM_MUSIC); - - //set background arrow style - getSupportActionBar().setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp); - - isLandscape = checkIfLandscape(); - hasSoftKeys = checkIfHasSoftKeys(); - - actionBar = getSupportActionBar(); - assert actionBar != null; - actionBar.setDisplayHomeAsUpEnabled(true); - Intent intent = getIntent(); - if(mediaController == null) { - //prevents back button hiding media controller controls (after showing them) - //instead of exiting video - //see http://stackoverflow.com/questions/6051825 - //also solves https://github.com/theScrabi/NewPipe/issues/99 - mediaController = new MediaController(this) { - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - int keyCode = event.getKeyCode(); - final boolean uniqueDown = event.getRepeatCount() == 0 - && event.getAction() == KeyEvent.ACTION_DOWN; - if (keyCode == KeyEvent.KEYCODE_BACK) { - if (uniqueDown) - { - if (isShowing()) { - finish(); - } else { - hide(); - } - } - return true; - } - return super.dispatchKeyEvent(event); - } - }; - } - - position = intent.getIntExtra(START_POSITION, 0)*1000;//convert from seconds to milliseconds - - videoView = findViewById(R.id.video_view); - progressBar = findViewById(R.id.play_video_progress_bar); - try { - videoView.setMediaController(mediaController); - videoView.setVideoURI(Uri.parse(intent.getStringExtra(STREAM_URL))); - } catch (Exception e) { - e.printStackTrace(); - } - videoView.requestFocus(); - videoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { - @Override - public void onPrepared(MediaPlayer mp) { - progressBar.setVisibility(View.GONE); - videoView.seekTo(position); - if (position <= 0) { - videoView.start(); - showUi(); - } else { - videoView.pause(); - } - } - }); - videoUrl = intent.getStringExtra(VIDEO_URL); - - Button button = findViewById(R.id.content_button); - button.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - if(uiIsHidden) { - showUi(); - } else { - hideUi(); - } - } - }); - decorView = getWindow().getDecorView(); - decorView.setOnSystemUiVisibilityChangeListener(new View.OnSystemUiVisibilityChangeListener() { - @Override - public void onSystemUiVisibilityChange(int visibility) { - if (visibility == View.VISIBLE && uiIsHidden) { - showUi(); - } - } - }); - - if (android.os.Build.VERSION.SDK_INT >= 17) { - decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - - prefs = getPreferences(Context.MODE_PRIVATE); - if(prefs.getBoolean(PREF_IS_LANDSCAPE, false) && !isLandscape) { - toggleOrientation(); - } - } - - @Override - public boolean onCreatePanelMenu(int featured, Menu menu) { - super.onCreatePanelMenu(featured, menu); - MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.video_player, menu); - - return true; - } - - @Override - public void onPause() { - super.onPause(); - videoView.pause(); - } - - @Override - public void onResume() { - super.onResume(); - } - - @Override - protected void onDestroy() { - super.onDestroy(); - prefs = getPreferences(Context.MODE_PRIVATE); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - int id = item.getItemId(); - switch(id) { - case android.R.id.home: - finish(); - break; - case R.id.menu_item_share: - Intent intent = new Intent(); - intent.setAction(Intent.ACTION_SEND); - intent.putExtra(Intent.EXTRA_TEXT, videoUrl); - intent.setType("text/plain"); - startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); - break; - case R.id.menu_item_screen_rotation: - toggleOrientation(); - break; - default: - Log.e(TAG, "Error: MenuItem not known"); - return false; - } - return true; - } - - @Override - public void onConfigurationChanged(Configuration config) { - super.onConfigurationChanged(config); - - if (config.orientation == Configuration.ORIENTATION_LANDSCAPE) { - isLandscape = true; - adjustMediaControlMetrics(); - } else if (config.orientation == Configuration.ORIENTATION_PORTRAIT){ - isLandscape = false; - adjustMediaControlMetrics(); - } - } - - @Override - public void onSaveInstanceState(Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - //savedInstanceState.putInt(POSITION, videoView.getCurrentPosition()); - //videoView.pause(); - } - - @Override - public void onRestoreInstanceState(Bundle savedInstanceState) { - super.onRestoreInstanceState(savedInstanceState); - position = savedInstanceState.getInt(POSITION); - //videoView.seekTo(position); - } - - private void showUi() { - try { - uiIsHidden = false; - mediaController.show(100000); - actionBar.show(); - adjustMediaControlMetrics(); - getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - Handler handler = new Handler(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - if ((System.currentTimeMillis() - lastUiShowTime) >= HIDING_DELAY) { - hideUi(); - } - } - }, HIDING_DELAY); - lastUiShowTime = System.currentTimeMillis(); - }catch(Exception e) { - e.printStackTrace(); - } - } - - private void hideUi() { - uiIsHidden = true; - actionBar.hide(); - mediaController.hide(); - if (android.os.Build.VERSION.SDK_INT >= 17) { - decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, - WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - private void adjustMediaControlMetrics() { - MediaController.LayoutParams mediaControllerLayout - = new MediaController.LayoutParams(MediaController.LayoutParams.MATCH_PARENT, - MediaController.LayoutParams.WRAP_CONTENT); - - if(!hasSoftKeys) { - mediaControllerLayout.setMargins(20, 0, 20, 20); - } else { - int width = getNavigationBarWidth(); - int height = getNavigationBarHeight(); - mediaControllerLayout.setMargins(width + 20, 0, width + 20, height + 20); - } - mediaController.setLayoutParams(mediaControllerLayout); - } - - private boolean checkIfHasSoftKeys(){ - return Build.VERSION.SDK_INT >= 17 || - getNavigationBarHeight() != 0 || - getNavigationBarWidth() != 0; - } - - private int getNavigationBarHeight() { - if(Build.VERSION.SDK_INT >= 17) { - Display d = getWindowManager().getDefaultDisplay(); - - DisplayMetrics realDisplayMetrics = new DisplayMetrics(); - d.getRealMetrics(realDisplayMetrics); - DisplayMetrics displayMetrics = new DisplayMetrics(); - d.getMetrics(displayMetrics); - - int realHeight = realDisplayMetrics.heightPixels; - int displayHeight = displayMetrics.heightPixels; - return realHeight - displayHeight; - } else { - return 50; - } - } - - private int getNavigationBarWidth() { - if(Build.VERSION.SDK_INT >= 17) { - Display d = getWindowManager().getDefaultDisplay(); - - DisplayMetrics realDisplayMetrics = new DisplayMetrics(); - d.getRealMetrics(realDisplayMetrics); - DisplayMetrics displayMetrics = new DisplayMetrics(); - d.getMetrics(displayMetrics); - - int realWidth = realDisplayMetrics.widthPixels; - int displayWidth = displayMetrics.widthPixels; - return realWidth - displayWidth; - } else { - return 50; - } - } - - private boolean checkIfLandscape() { - DisplayMetrics displayMetrics = new DisplayMetrics(); - getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - return displayMetrics.heightPixels < displayMetrics.widthPixels; - } - - private void toggleOrientation() { - if(isLandscape) { - isLandscape = false; - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } else { - isLandscape = true; - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE); - } - SharedPreferences.Editor editor = prefs.edit(); - editor.putBoolean(PREF_IS_LANDSCAPE, isLandscape); - editor.apply(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java index c9e07c96a..2a7c9f127 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java @@ -233,6 +233,9 @@ public abstract class PlayQueue implements Serializable { backup.addAll(itemList); Collections.shuffle(itemList); } + if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued() && !itemList.get(0).isAutoQueued()) { + streams.remove(streams.size() - 1); + } streams.addAll(itemList); broadcast(new AppendEvent(itemList.size())); @@ -314,7 +317,9 @@ public abstract class PlayQueue implements Serializable { queueIndex.incrementAndGet(); } - streams.add(target, streams.remove(source)); + PlayQueueItem playQueueItem = streams.remove(source); + playQueueItem.setAutoQueued(false); + streams.add(target, playQueueItem); broadcast(new MoveEvent(source, target)); } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java index 8cbc3ed1c..bd0218454 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java @@ -25,9 +25,10 @@ public class PlayQueueItem implements Serializable { @NonNull final private String uploader; @NonNull final private StreamType streamType; + private boolean isAutoQueued; + private long recoveryPosition; private Throwable error; - PlayQueueItem(@NonNull final StreamInfo info) { this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), info.getThumbnailUrl(), info.getUploaderName(), info.getStreamType()); @@ -105,6 +106,14 @@ public class PlayQueueItem implements Serializable { .doOnError(throwable -> error = throwable); } + public boolean isAutoQueued() { + return isAutoQueued; + } + + public void setAutoQueued(boolean autoQueued) { + isAutoQueued = autoQueued; + } + //////////////////////////////////////////////////////////////////////////// // Item States, keep external access out //////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java index 6edeff670..26be83b98 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java @@ -8,11 +8,13 @@ public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleC private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; public PlayQueueItemTouchCallback() { - super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, 0); + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT); } public abstract void onMove(final int sourceIndex, final int targetIndex); + public abstract void onSwiped(int index); + @Override public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, int viewSizeOutOfBounds, int totalSize, @@ -44,9 +46,11 @@ public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleC @Override public boolean isItemViewSwipeEnabled() { - return false; + return true; } @Override - public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {} + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) { + onSwiped(viewHolder.getAdapterPosition()); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 8f91f4886..2dcf4a6ec 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -10,9 +10,10 @@ import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.Subtitles; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; @@ -93,8 +94,8 @@ public class VideoPlaybackResolver implements PlaybackResolver { // Below are auxiliary media sources // Create subtitle sources - for (final Subtitles subtitle : info.getSubtitles()) { - final String mimeType = PlayerHelper.mimeTypesOf(subtitle.getFileType()); + for (final SubtitlesStream subtitle : info.getSubtitles()) { + final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); if (mimeType == null) continue; final Format textFormat = Format.createTextSampleFormat(null, mimeType, diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 5c54fa735..82604f7da 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -17,6 +17,8 @@ import com.nononsenseapps.filepicker.Utils; import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.utils.Localization; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.FilePickerActivityHelper; @@ -106,6 +108,20 @@ public class ContentSettingsFragment extends BasePreferenceFragment { startActivityForResult(i, REQUEST_EXPORT_PATH); return true; }); + + Preference setPreferredLanguage = findPreference(getString(R.string.content_language_key)); + setPreferredLanguage.setOnPreferenceChangeListener((Preference p, Object newLanguage) -> { + Localization oldLocal = org.schabi.newpipe.util.Localization.getPreferredExtractorLocal(getActivity()); + NewPipe.setLocalization(new Localization(oldLocal.getCountry(), (String) newLanguage)); + return true; + }); + + Preference setPreferredCountry = findPreference(getString(R.string.content_country_key)); + setPreferredCountry.setOnPreferenceChangeListener((Preference p, Object newCountry) -> { + Localization oldLocal = org.schabi.newpipe.util.Localization.getPreferredExtractorLocal(getActivity()); + NewPipe.setLocalization(new Localization((String) newCountry, oldLocal.getLanguage())); + return true; + }); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java new file mode 100644 index 000000000..d0e946eb7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java @@ -0,0 +1,103 @@ +package org.schabi.newpipe.streams; + +import java.io.EOFException; +import java.io.IOException; + +import org.schabi.newpipe.streams.io.SharpStream; + +/** + * @author kapodamy + */ +public class DataReader { + + public final static int SHORT_SIZE = 2; + public final static int LONG_SIZE = 8; + public final static int INTEGER_SIZE = 4; + public final static int FLOAT_SIZE = 4; + + private long pos; + public final SharpStream stream; + private final boolean rewind; + + public DataReader(SharpStream stream) { + this.rewind = stream.canRewind(); + this.stream = stream; + this.pos = 0L; + } + + public long position() { + return pos; + } + + public final int readInt() throws IOException { + primitiveRead(INTEGER_SIZE); + return primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; + } + + public final int read() throws IOException { + int value = stream.read(); + if (value == -1) { + throw new EOFException(); + } + + pos++; + return value; + } + + public final long skipBytes(long amount) throws IOException { + amount = stream.skip(amount); + pos += amount; + return amount; + } + + public final long readLong() throws IOException { + primitiveRead(LONG_SIZE); + long high = primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; + long low = primitive[4] << 24 | primitive[5] << 16 | primitive[6] << 8 | primitive[7]; + return high << 32 | low; + } + + public final short readShort() throws IOException { + primitiveRead(SHORT_SIZE); + return (short) (primitive[0] << 8 | primitive[1]); + } + + public final int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + public final int read(byte[] buffer, int offset, int count) throws IOException { + int res = stream.read(buffer, offset, count); + pos += res; + + return res; + } + + public final boolean available() { + return stream.available() > 0; + } + + public void rewind() throws IOException { + stream.rewind(); + pos = 0; + } + + public boolean canRewind() { + return rewind; + } + + private short[] primitive = new short[LONG_SIZE]; + + private void primitiveRead(int amount) throws IOException { + byte[] buffer = new byte[amount]; + int read = stream.read(buffer, 0, amount); + pos += read; + if (read != amount) { + throw new EOFException("Truncated data, missing " + String.valueOf(amount - read) + " bytes"); + } + + for (int i = 0; i < buffer.length; i++) { + primitive[i] = (short) (buffer[i] & 0xFF);// the "byte" datatype is signed and is very annoying + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java new file mode 100644 index 000000000..271929d47 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java @@ -0,0 +1,817 @@ +package org.schabi.newpipe.streams; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +import java.nio.ByteBuffer; + +import java.util.ArrayList; +import java.util.NoSuchElementException; + +import org.schabi.newpipe.streams.io.SharpStream; + +/** + * @author kapodamy + */ +public class Mp4DashReader { + + // + private static final int ATOM_MOOF = 0x6D6F6F66; + private static final int ATOM_MFHD = 0x6D666864; + private static final int ATOM_TRAF = 0x74726166; + private static final int ATOM_TFHD = 0x74666864; + private static final int ATOM_TFDT = 0x74666474; + private static final int ATOM_TRUN = 0x7472756E; + private static final int ATOM_MDIA = 0x6D646961; + private static final int ATOM_FTYP = 0x66747970; + private static final int ATOM_SIDX = 0x73696478; + private static final int ATOM_MOOV = 0x6D6F6F76; + private static final int ATOM_MDAT = 0x6D646174; + private static final int ATOM_MVHD = 0x6D766864; + private static final int ATOM_TRAK = 0x7472616B; + private static final int ATOM_MVEX = 0x6D766578; + private static final int ATOM_TREX = 0x74726578; + private static final int ATOM_TKHD = 0x746B6864; + private static final int ATOM_MFRA = 0x6D667261; + private static final int ATOM_TFRA = 0x74667261; + private static final int ATOM_MDHD = 0x6D646864; + private static final int BRAND_DASH = 0x64617368; + // + + private final DataReader stream; + + private Mp4Track[] tracks = null; + + private Box box; + private Moof moof; + + private boolean chunkZero = false; + + private int selectedTrack = -1; + + public enum TrackKind { + Audio, Video, Other + } + + public Mp4DashReader(SharpStream source) { + this.stream = new DataReader(source); + } + + public void parse() throws IOException, NoSuchElementException { + if (selectedTrack > -1) { + return; + } + + box = readBox(ATOM_FTYP); + if (parse_ftyp() != BRAND_DASH) { + throw new NoSuchElementException("Main Brand is not dash"); + } + + Moov moov = null; + int i; + + while (box.type != ATOM_MOOF) { + ensure(box); + box = readBox(); + + switch (box.type) { + case ATOM_MOOV: + moov = parse_moov(box); + break; + case ATOM_SIDX: + break; + case ATOM_MFRA: + break; + case ATOM_MDAT: + throw new IOException("Expected moof, found mdat"); + } + } + + if (moov == null) { + throw new IOException("The provided Mp4 doesn't have the 'moov' box"); + } + + tracks = new Mp4Track[moov.trak.length]; + + for (i = 0; i < tracks.length; i++) { + tracks[i] = new Mp4Track(); + tracks[i].trak = moov.trak[i]; + + if (moov.mvex_trex != null) { + for (Trex mvex_trex : moov.mvex_trex) { + if (tracks[i].trak.tkhd.trackId == mvex_trex.trackId) { + tracks[i].trex = mvex_trex; + } + } + } + + if (moov.trak[i].tkhd.bHeight == 0 && moov.trak[i].tkhd.bWidth == 0) { + tracks[i].kind = moov.trak[i].tkhd.bVolume == 0 ? TrackKind.Other : TrackKind.Audio; + } else { + tracks[i].kind = TrackKind.Video; + } + } + } + + public Mp4Track selectTrack(int index) { + selectedTrack = index; + return tracks[index]; + } + + /** + * Count all fragments present. This operation requires a seekable stream + * + * @return list with a basic info + * @throws IOException if the source stream is not seekeable + */ + public int getFragmentsCount() throws IOException { + if (selectedTrack < 0) { + throw new IllegalStateException("track no selected"); + } + if (!stream.canRewind()) { + throw new IOException("The provided stream doesn't allow seek"); + } + + Box tmp; + int count = 0; + long orig_offset = stream.position(); + + if (box.type == ATOM_MOOF) { + tmp = box; + } else { + ensure(box); + tmp = readBox(); + } + + do { + if (tmp.type == ATOM_MOOF) { + ensure(readBox(ATOM_MFHD)); + Box traf; + while ((traf = untilBox(tmp, ATOM_TRAF)) != null) { + Box tfhd = readBox(ATOM_TFHD); + if (parse_tfhd(tracks[selectedTrack].trak.tkhd.trackId) != null) { + count++; + break; + } + ensure(tfhd); + ensure(traf); + } + } + ensure(tmp); + } while (stream.available() && (tmp = readBox()) != null); + + stream.rewind(); + stream.skipBytes((int) orig_offset); + + return count; + } + + public Mp4Track[] getAvailableTracks() { + return tracks; + } + + public Mp4TrackChunk getNextChunk() throws IOException { + Mp4Track track = tracks[selectedTrack]; + + while (stream.available()) { + + if (chunkZero) { + ensure(box); + if (!stream.available()) { + break; + } + box = readBox(); + } else { + chunkZero = true; + } + + switch (box.type) { + case ATOM_MOOF: + if (moof != null) { + throw new IOException("moof found without mdat"); + } + + moof = parse_moof(box, track.trak.tkhd.trackId); + + if (moof.traf != null) { + + if (hasFlag(moof.traf.trun.bFlags, 0x0001)) { + moof.traf.trun.dataOffset -= box.size + 8; + if (moof.traf.trun.dataOffset < 0) { + throw new IOException("trun box has wrong data offset, points outside of concurrent mdat box"); + } + } + + if (moof.traf.trun.chunkSize < 1) { + if (hasFlag(moof.traf.tfhd.bFlags, 0x10)) { + moof.traf.trun.chunkSize = moof.traf.tfhd.defaultSampleSize * moof.traf.trun.entryCount; + } else { + moof.traf.trun.chunkSize = box.size - 8; + } + } + if (!hasFlag(moof.traf.trun.bFlags, 0x900) && moof.traf.trun.chunkDuration == 0) { + if (hasFlag(moof.traf.tfhd.bFlags, 0x20)) { + moof.traf.trun.chunkDuration = moof.traf.tfhd.defaultSampleDuration * moof.traf.trun.entryCount; + } + } + } + break; + case ATOM_MDAT: + if (moof == null) { + throw new IOException("mdat found without moof"); + } + + if (moof.traf == null) { + moof = null; + continue;// find another chunk + } + + Mp4TrackChunk chunk = new Mp4TrackChunk(); + chunk.moof = moof; + chunk.data = new TrackDataChunk(stream, moof.traf.trun.chunkSize); + moof = null; + + stream.skipBytes(chunk.moof.traf.trun.dataOffset); + return chunk; + default: + } + } + + return null; + } + + // + private long readUint() throws IOException { + return stream.readInt() & 0xffffffffL; + } + + public static boolean hasFlag(int flags, int mask) { + return (flags & mask) == mask; + } + + private String boxName(Box ref) { + return boxName(ref.type); + } + + private String boxName(int type) { + try { + return new String(ByteBuffer.allocate(4).putInt(type).array(), "UTF-8"); + } catch (UnsupportedEncodingException e) { + return "0x" + Integer.toHexString(type); + } + } + + private Box readBox() throws IOException { + Box b = new Box(); + b.offset = stream.position(); + b.size = stream.readInt(); + b.type = stream.readInt(); + + return b; + } + + private Box readBox(int expected) throws IOException { + Box b = readBox(); + if (b.type != expected) { + throw new NoSuchElementException("expected " + boxName(expected) + " found " + boxName(b)); + } + return b; + } + + private void ensure(Box ref) throws IOException { + long skip = ref.offset + ref.size - stream.position(); + + if (skip == 0) { + return; + } else if (skip < 0) { + throw new EOFException(String.format( + "parser go beyond limits of the box. type=%s offset=%s size=%s position=%s", + boxName(ref), ref.offset, ref.size, stream.position() + )); + } + + stream.skipBytes((int) skip); + } + + private Box untilBox(Box ref, int... expected) throws IOException { + Box b; + while (stream.position() < (ref.offset + ref.size)) { + b = readBox(); + for (int type : expected) { + if (b.type == type) { + return b; + } + } + ensure(b); + } + + return null; + } + + // + + // + + private Moof parse_moof(Box ref, int trackId) throws IOException { + Moof obj = new Moof(); + + Box b = readBox(ATOM_MFHD); + obj.mfhd_SequenceNumber = parse_mfhd(); + ensure(b); + + while ((b = untilBox(ref, ATOM_TRAF)) != null) { + obj.traf = parse_traf(b, trackId); + ensure(b); + + if (obj.traf != null) { + return obj; + } + } + + return obj; + } + + private int parse_mfhd() throws IOException { + // version + // flags + stream.skipBytes(4); + + return stream.readInt(); + } + + private Traf parse_traf(Box ref, int trackId) throws IOException { + Traf traf = new Traf(); + + Box b = readBox(ATOM_TFHD); + traf.tfhd = parse_tfhd(trackId); + ensure(b); + + if (traf.tfhd == null) { + return null; + } + + b = untilBox(ref, ATOM_TRUN, ATOM_TFDT); + + if (b.type == ATOM_TFDT) { + traf.tfdt = parse_tfdt(); + ensure(b); + b = readBox(ATOM_TRUN); + } + + traf.trun = parse_trun(); + ensure(b); + + return traf; + } + + private Tfhd parse_tfhd(int trackId) throws IOException { + Tfhd obj = new Tfhd(); + + obj.bFlags = stream.readInt(); + obj.trackId = stream.readInt(); + + if (trackId != -1 && obj.trackId != trackId) { + return null; + } + + if (hasFlag(obj.bFlags, 0x01)) { + stream.skipBytes(8); + } + if (hasFlag(obj.bFlags, 0x02)) { + stream.skipBytes(4); + } + if (hasFlag(obj.bFlags, 0x08)) { + obj.defaultSampleDuration = stream.readInt(); + } + if (hasFlag(obj.bFlags, 0x10)) { + obj.defaultSampleSize = stream.readInt(); + } + if (hasFlag(obj.bFlags, 0x20)) { + obj.defaultSampleFlags = stream.readInt(); + } + + return obj; + } + + private long parse_tfdt() throws IOException { + int version = stream.read(); + stream.skipBytes(3);// flags + return version == 0 ? readUint() : stream.readLong(); + } + + private Trun parse_trun() throws IOException { + Trun obj = new Trun(); + obj.bFlags = stream.readInt(); + obj.entryCount = stream.readInt();// unsigned int + + obj.entries_rowSize = 0; + if (hasFlag(obj.bFlags, 0x0100)) { + obj.entries_rowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0200)) { + obj.entries_rowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0400)) { + obj.entries_rowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0800)) { + obj.entries_rowSize += 4; + } + obj.bEntries = new byte[obj.entries_rowSize * obj.entryCount]; + + if (hasFlag(obj.bFlags, 0x0001)) { + obj.dataOffset = stream.readInt(); + } + if (hasFlag(obj.bFlags, 0x0004)) { + obj.bFirstSampleFlags = stream.readInt(); + } + + stream.read(obj.bEntries); + + for (int i = 0; i < obj.entryCount; i++) { + TrunEntry entry = obj.getEntry(i); + if (hasFlag(obj.bFlags, 0x0100)) { + obj.chunkDuration += entry.sampleDuration; + } + if (hasFlag(obj.bFlags, 0x0200)) { + obj.chunkSize += entry.sampleSize; + } + if (hasFlag(obj.bFlags, 0x0800)) { + if (!hasFlag(obj.bFlags, 0x0100)) { + obj.chunkDuration += entry.sampleCompositionTimeOffset; + } + } + } + + return obj; + } + + private int parse_ftyp() throws IOException { + int brand = stream.readInt(); + stream.skipBytes(4);// minor version + + return brand; + } + + private Mvhd parse_mvhd() throws IOException { + int version = stream.read(); + stream.skipBytes(3);// flags + + // creation entries_time + // modification entries_time + stream.skipBytes(2 * (version == 0 ? 4 : 8)); + + Mvhd obj = new Mvhd(); + obj.timeScale = readUint(); + + // chunkDuration + stream.skipBytes(version == 0 ? 4 : 8); + + // rate + // volume + // reserved + // matrix array + // predefined + stream.skipBytes(76); + + obj.nextTrackId = readUint(); + + return obj; + } + + private Tkhd parse_tkhd() throws IOException { + int version = stream.read(); + + Tkhd obj = new Tkhd(); + + // flags + // creation entries_time + // modification entries_time + stream.skipBytes(3 + (2 * (version == 0 ? 4 : 8))); + + obj.trackId = stream.readInt(); + + stream.skipBytes(4);// reserved + + obj.duration = version == 0 ? readUint() : stream.readLong(); + + stream.skipBytes(2 * 4);// reserved + + obj.bLayer = stream.readShort(); + obj.bAlternateGroup = stream.readShort(); + obj.bVolume = stream.readShort(); + + stream.skipBytes(2);// reserved + + obj.matrix = new byte[9 * 4]; + stream.read(obj.matrix); + + obj.bWidth = stream.readInt(); + obj.bHeight = stream.readInt(); + + return obj; + } + + private Trak parse_trak(Box ref) throws IOException { + Trak trak = new Trak(); + + Box b = readBox(ATOM_TKHD); + trak.tkhd = parse_tkhd(); + ensure(b); + + b = untilBox(ref, ATOM_MDIA); + trak.mdia = new byte[b.size]; + + ByteBuffer buffer = ByteBuffer.wrap(trak.mdia); + buffer.putInt(b.size); + buffer.putInt(ATOM_MDIA); + stream.read(trak.mdia, 8, b.size - 8); + + trak.mdia_mdhd_timeScale = parse_mdia(buffer); + + return trak; + } + + private int parse_mdia(ByteBuffer data) { + while (data.hasRemaining()) { + int end = data.position() + data.getInt(); + if (data.getInt() == ATOM_MDHD) { + byte version = data.get(); + data.position(data.position() + 3 + ((version == 0 ? 4 : 8) * 2)); + return data.getInt(); + } + + data.position(end); + } + + return 0;// this NEVER should happen + } + + private Moov parse_moov(Box ref) throws IOException { + Box b = readBox(ATOM_MVHD); + Moov moov = new Moov(); + moov.mvhd = parse_mvhd(); + ensure(b); + + ArrayList tmp = new ArrayList<>((int) moov.mvhd.nextTrackId); + while ((b = untilBox(ref, ATOM_TRAK, ATOM_MVEX)) != null) { + + switch (b.type) { + case ATOM_TRAK: + tmp.add(parse_trak(b)); + break; + case ATOM_MVEX: + moov.mvex_trex = parse_mvex(b, (int) moov.mvhd.nextTrackId); + break; + } + + ensure(b); + } + + moov.trak = tmp.toArray(new Trak[tmp.size()]); + + return moov; + } + + private Trex[] parse_mvex(Box ref, int possibleTrackCount) throws IOException { + ArrayList tmp = new ArrayList<>(possibleTrackCount); + + Box b; + while ((b = untilBox(ref, ATOM_TREX)) != null) { + tmp.add(parse_trex()); + ensure(b); + } + + return tmp.toArray(new Trex[tmp.size()]); + } + + private Trex parse_trex() throws IOException { + // version + // flags + stream.skipBytes(4); + + Trex obj = new Trex(); + obj.trackId = stream.readInt(); + obj.defaultSampleDescriptionIndex = stream.readInt(); + obj.defaultSampleDuration = stream.readInt(); + obj.defaultSampleSize = stream.readInt(); + obj.defaultSampleFlags = stream.readInt(); + + return obj; + } + + private Tfra parse_tfra() throws IOException { + int version = stream.read(); + + stream.skipBytes(3);// flags + + Tfra tfra = new Tfra(); + tfra.trackId = stream.readInt(); + + stream.skipBytes(3);// reserved + int bFlags = stream.read(); + int size_tts = ((bFlags >> 4) & 3) + ((bFlags >> 2) & 3) + (bFlags & 3); + + tfra.entries_time = new int[stream.readInt()]; + + for (int i = 0; i < tfra.entries_time.length; i++) { + tfra.entries_time[i] = version == 0 ? stream.readInt() : (int) stream.readLong(); + stream.skipBytes(size_tts + (version == 0 ? 4 : 8)); + } + + return tfra; + } + + private Sidx parse_sidx() throws IOException { + int version = stream.read(); + + stream.skipBytes(3);// flags + + Sidx obj = new Sidx(); + obj.referenceId = stream.readInt(); + obj.timescale = stream.readInt(); + + // earliest presentation entries_time + // first offset + // reserved + stream.skipBytes((2 * (version == 0 ? 4 : 8)) + 2); + + obj.entries_subsegmentDuration = new int[stream.readShort()]; + + for (int i = 0; i < obj.entries_subsegmentDuration.length; i++) { + // reference type + // referenced size + stream.skipBytes(4); + obj.entries_subsegmentDuration[i] = stream.readInt();// unsigned int + + // starts with SAP + // SAP type + // SAP delta entries_time + stream.skipBytes(4); + } + + return obj; + } + + private Tfra[] parse_mfra(Box ref, int trackCount) throws IOException { + ArrayList tmp = new ArrayList<>(trackCount); + long limit = ref.offset + ref.size; + + while (stream.position() < limit) { + box = readBox(); + + if (box.type == ATOM_TFRA) { + tmp.add(parse_tfra()); + } + + ensure(box); + } + + return tmp.toArray(new Tfra[tmp.size()]); + } + + // + + // + class Box { + + int type; + long offset; + int size; + } + + class Sidx { + + int timescale; + int referenceId; + int[] entries_subsegmentDuration; + } + + public class Moof { + + int mfhd_SequenceNumber; + public Traf traf; + } + + public class Traf { + + public Tfhd tfhd; + long tfdt; + public Trun trun; + } + + public class Tfhd { + + int bFlags; + public int trackId; + int defaultSampleDuration; + int defaultSampleSize; + int defaultSampleFlags; + } + + public class TrunEntry { + + public int sampleDuration; + public int sampleSize; + public int sampleFlags; + public int sampleCompositionTimeOffset; + } + + public class Trun { + + public int chunkDuration; + public int chunkSize; + + public int bFlags; + int bFirstSampleFlags; + int dataOffset; + + public int entryCount; + byte[] bEntries; + int entries_rowSize; + + public TrunEntry getEntry(int i) { + ByteBuffer buffer = ByteBuffer.wrap(bEntries, i * entries_rowSize, entries_rowSize); + TrunEntry entry = new TrunEntry(); + + if (hasFlag(bFlags, 0x0100)) { + entry.sampleDuration = buffer.getInt(); + } + if (hasFlag(bFlags, 0x0200)) { + entry.sampleSize = buffer.getInt(); + } + if (hasFlag(bFlags, 0x0400)) { + entry.sampleFlags = buffer.getInt(); + } + if (hasFlag(bFlags, 0x0800)) { + entry.sampleCompositionTimeOffset = buffer.getInt(); + } + + return entry; + } + } + + public class Tkhd { + + int trackId; + long duration; + short bVolume; + int bWidth; + int bHeight; + byte[] matrix; + short bLayer; + short bAlternateGroup; + } + + public class Trak { + + public Tkhd tkhd; + public int mdia_mdhd_timeScale; + + byte[] mdia; + } + + class Mvhd { + + long timeScale; + long nextTrackId; + } + + class Moov { + + Mvhd mvhd; + Trak[] trak; + Trex[] mvex_trex; + } + + class Tfra { + + int trackId; + int[] entries_time; + } + + public class Trex { + + private int trackId; + int defaultSampleDescriptionIndex; + int defaultSampleDuration; + int defaultSampleSize; + int defaultSampleFlags; + } + + public class Mp4Track { + + public TrackKind kind; + public Trak trak; + public Trex trex; + } + + public class Mp4TrackChunk { + + public InputStream data; + public Moof moof; + } +// +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java new file mode 100644 index 000000000..babb2e24c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashWriter.java @@ -0,0 +1,623 @@ +package org.schabi.newpipe.streams; + +import org.schabi.newpipe.streams.io.SharpStream; + +import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; +import org.schabi.newpipe.streams.Mp4DashReader.Mp4TrackChunk; +import org.schabi.newpipe.streams.Mp4DashReader.Trak; +import org.schabi.newpipe.streams.Mp4DashReader.Trex; + + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; + +import static org.schabi.newpipe.streams.Mp4DashReader.hasFlag; + +/** + * + * @author kapodamy + */ +public class Mp4DashWriter { + + private final static byte DIMENSIONAL_FIVE = 5; + private final static byte DIMENSIONAL_TWO = 2; + private final static short DEFAULT_TIMESCALE = 1000; + private final static int BUFFER_SIZE = 8 * 1024; + private final static byte DEFAULT_TREX_SIZE = 32; + private final static byte[] TFRA_TTS_DEFAULT = new byte[]{0x01, 0x01, 0x01}; + private final static int EPOCH_OFFSET = 2082844800; + + private Mp4Track[] infoTracks; + private SharpStream[] sourceTracks; + + private Mp4DashReader[] readers; + private final long time; + + private boolean done = false; + private boolean parsed = false; + + private long written = 0; + private ArrayList> chunkTimes; + private ArrayList moofOffsets; + private ArrayList fragSizes; + + public Mp4DashWriter(SharpStream... source) { + sourceTracks = source; + readers = new Mp4DashReader[sourceTracks.length]; + infoTracks = new Mp4Track[sourceTracks.length]; + time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; + } + + public Mp4Track[] getTracksFromSource(int sourceIndex) throws IllegalStateException { + if (!parsed) { + throw new IllegalStateException("All sources must be parsed first"); + } + + return readers[sourceIndex].getAvailableTracks(); + } + + public void parseSources() throws IOException, IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (parsed) { + throw new IllegalStateException("already parsed"); + } + + try { + for (int i = 0; i < readers.length; i++) { + readers[i] = new Mp4DashReader(sourceTracks[i]); + readers[i].parse(); + } + + } finally { + parsed = true; + } + } + + public void selectTracks(int... trackIndex) throws IOException { + if (done) { + throw new IOException("already done"); + } + if (chunkTimes != null) { + throw new IOException("tracks already selected"); + } + + try { + chunkTimes = new ArrayList<>(readers.length); + moofOffsets = new ArrayList<>(32); + fragSizes = new ArrayList<>(32); + + for (int i = 0; i < readers.length; i++) { + infoTracks[i] = readers[i].selectTrack(trackIndex[i]); + + chunkTimes.add(new ArrayList(32)); + } + + } finally { + parsed = true; + } + } + + public long getBytesWritten() { + return written; + } + + public void build(SharpStream out) throws IOException, RuntimeException { + if (done) { + throw new RuntimeException("already done"); + } + if (!out.canWrite()) { + throw new IOException("the provided output is not writable"); + } + + long sidxOffsets = -1; + int maxFrags = 0; + + for (SharpStream stream : sourceTracks) { + if (!stream.canRewind()) { + sidxOffsets = -2;// sidx not available + } + } + + try { + dump(make_ftyp(), out); + dump(make_moov(), out); + + if (sidxOffsets == -1 && out.canRewind()) { + // + int reserved = 0; + for (Mp4DashReader reader : readers) { + int count = reader.getFragmentsCount(); + if (count > maxFrags) { + maxFrags = count; + } + reserved += 12 + calcSidxBodySize(count); + } + if (maxFrags > 0xFFFF) { + sidxOffsets = -3;// TODO: to many fragments, needs a multi-sidx implementation + } else { + sidxOffsets = written; + dump(make_free(reserved), out); + } + // + } + ArrayList chunks = new ArrayList<>(readers.length); + chunks.add(null); + + int read; + byte[] buffer = new byte[BUFFER_SIZE]; + int sequenceNumber = 1; + + while (true) { + chunks.clear(); + + for (int i = 0; i < readers.length; i++) { + Mp4TrackChunk chunk = readers[i].getNextChunk(); + if (chunk == null || chunk.moof.traf.trun.chunkSize < 1) { + continue; + } + chunk.moof.traf.tfhd.trackId = i + 1; + chunks.add(chunk); + + if (sequenceNumber == 1) { + if (chunk.moof.traf.trun.entryCount > 0 && hasFlag(chunk.moof.traf.trun.bFlags, 0x0800)) { + chunkTimes.get(i).add(chunk.moof.traf.trun.getEntry(0).sampleCompositionTimeOffset); + } else { + chunkTimes.get(i).add(0); + } + } + + chunkTimes.get(i).add(chunk.moof.traf.trun.chunkDuration); + } + + if (chunks.size() < 1) { + break; + } + + long offset = written; + moofOffsets.add(offset); + + dump(make_moof(sequenceNumber++, chunks, offset), out); + dump(make_mdat(chunks), out); + + for (Mp4TrackChunk chunk : chunks) { + while ((read = chunk.data.read(buffer)) > 0) { + out.write(buffer, 0, read); + written += read; + } + } + + fragSizes.add((int) (written - offset)); + } + + dump(make_mfra(), out); + + if (sidxOffsets > 0 && moofOffsets.size() == maxFrags) { + long len = written; + + out.rewind(); + out.skip(sidxOffsets); + + written = sidxOffsets; + sidxOffsets = moofOffsets.get(0); + + for (int i = 0; i < readers.length; i++) { + dump(make_sidx(i, sidxOffsets - written), out); + } + + written = len; + } + } finally { + done = true; + } + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public void close() { + done = true; + parsed = true; + + for (SharpStream src : sourceTracks) { + src.dispose(); + } + + sourceTracks = null; + readers = null; + infoTracks = null; + moofOffsets = null; + chunkTimes = null; + } + + // + private void dump(byte[][] buffer, SharpStream stream) throws IOException { + for (byte[] buff : buffer) { + stream.write(buff); + written += buff.length; + } + } + + private byte[][] lengthFor(byte[][] buffer) { + int length = 0; + for (byte[] buff : buffer) { + length += buff.length; + } + + ByteBuffer.wrap(buffer[0]).putInt(length); + + return buffer; + } + + private int calcSidxBodySize(int entryCount) { + return 4 + 4 + 8 + 8 + 4 + (entryCount * 12); + } + // + + // + private byte[][] make_moof(int sequence, ArrayList chunks, long referenceOffset) { + int pos = 2; + TrunExtra[] extra = new TrunExtra[chunks.size()]; + + byte[][] buffer = new byte[pos + (extra.length * DIMENSIONAL_FIVE)][]; + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x66,// info header + 0x00, 0x00, 0x00, 0x10, 0x6D, 0x66, 0x68, 0x64, 0x00, 0x00, 0x00, 0x00//mfhd + }; + buffer[1] = new byte[4]; + ByteBuffer.wrap(buffer[1]).putInt(sequence); + + for (int i = 0; i < extra.length; i++) { + extra[i] = new TrunExtra(); + for (byte[] buff : make_traf(chunks.get(i), extra[i], referenceOffset)) { + buffer[pos++] = buff; + } + } + + lengthFor(buffer); + + int offset = 8 + ByteBuffer.wrap(buffer[0]).getInt(); + + for (int i = 0; i < extra.length; i++) { + extra[i].byteBuffer.putInt(offset); + offset += chunks.get(i).moof.traf.trun.chunkSize; + } + + return buffer; + } + + private byte[][] make_traf(Mp4TrackChunk chunk, TrunExtra extra, long moofOffset) { + byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x66, + 0x00, 0x00, 0x00, 0x00, 0x74, 0x66, 0x68, 0x64 + }; + + int flags = (chunk.moof.traf.tfhd.bFlags & 0x38) | 0x01; + byte tfhdBodySize = 8 + 8; + if (hasFlag(flags, 0x08)) { + tfhdBodySize += 4; + } + if (hasFlag(flags, 0x10)) { + tfhdBodySize += 4; + } + if (hasFlag(flags, 0x20)) { + tfhdBodySize += 4; + } + buffer[1] = new byte[tfhdBodySize]; + ByteBuffer set = ByteBuffer.wrap(buffer[1]); + set.position(4); + set.putInt(chunk.moof.traf.tfhd.trackId); + set.putLong(moofOffset); + if (hasFlag(flags, 0x08)) { + set.putInt(chunk.moof.traf.tfhd.defaultSampleDuration); + } + if (hasFlag(flags, 0x10)) { + set.putInt(chunk.moof.traf.tfhd.defaultSampleSize); + } + if (hasFlag(flags, 0x20)) { + set.putInt(chunk.moof.traf.tfhd.defaultSampleFlags); + } + set.putInt(0, flags); + ByteBuffer.wrap(buffer[0]).putInt(8, 8 + tfhdBodySize); + + buffer[2] = new byte[]{ + 0x00, 0x00, 0x00, 0x14, + 0x74, 0x66, 0x64, 0x74, + 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + }; + + ByteBuffer.wrap(buffer[2]).putLong(12, chunk.moof.traf.tfdt); + + buffer[3] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x75, 0x6E, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 + }; + + buffer[4] = chunk.moof.traf.trun.bEntries; + + lengthFor(buffer); + + set = ByteBuffer.wrap(buffer[3]); + set.putInt(buffer[3].length + buffer[4].length); + set.position(8); + set.putInt((chunk.moof.traf.trun.bFlags | 0x01) & 0x0F01); + set.putInt(chunk.moof.traf.trun.entryCount); + extra.byteBuffer = set; + + return buffer; + } + + private byte[][] make_mdat(ArrayList chunks) { + byte[][] buffer = new byte[][]{ + { + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x61, 0x74 + } + }; + + int length = 0; + + for (Mp4TrackChunk chunk : chunks) { + length += chunk.moof.traf.trun.chunkSize; + } + + ByteBuffer.wrap(buffer[0]).putInt(length + 8); + + return buffer; + } + + private byte[][] make_ftyp() { + return new byte[][]{ + { + 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x64, 0x61, 0x73, 0x68, 0x00, 0x00, 0x00, 0x00, + 0x6D, 0x70, 0x34, 0x31, 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x36, 0x69, 0x73, 0x6F, 0x32 + } + }; + } + + private byte[][] make_mvhd() { + byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; + + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 + }; + buffer[1] = new byte[28]; + buffer[2] = new byte[]{ + 0x00, 0x01, 0x00, 0x00, 0x01, 0x00,// default volume and rate + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,// reserved values + // default matrix + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x40, 0x00, 0x00, 0x00 + }; + buffer[3] = new byte[24];// predefined + buffer[4] = ByteBuffer.allocate(4).putInt(infoTracks.length + 1).array(); + + long longestTrack = 0; + + for (Mp4Track track : infoTracks) { + long tmp = (long) ((track.trak.tkhd.duration / (double) track.trak.mdia_mdhd_timeScale) * DEFAULT_TIMESCALE); + if (tmp > longestTrack) { + longestTrack = tmp; + } + } + + ByteBuffer.wrap(buffer[1]) + .putLong(time) + .putLong(time) + .putInt(DEFAULT_TIMESCALE) + .putLong(longestTrack); + + return buffer; + } + + private byte[][] make_trak(int trackId, Trak trak) throws RuntimeException { + if (trak.tkhd.matrix.length != 36) { + throw new RuntimeException("bad track matrix length (expected 36)"); + } + + byte[][] buffer = new byte[DIMENSIONAL_FIVE][]; + + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B,// trak header + 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 // tkhd header + }; + buffer[1] = new byte[48]; + buffer[2] = trak.tkhd.matrix; + buffer[3] = new byte[8]; + buffer[4] = trak.mdia; + + ByteBuffer set = ByteBuffer.wrap(buffer[1]); + set.putLong(time); + set.putLong(time); + set.putInt(trackId); + set.position(24); + set.putLong(trak.tkhd.duration); + set.position(40); + set.putShort(trak.tkhd.bLayer); + set.putShort(trak.tkhd.bAlternateGroup); + set.putShort(trak.tkhd.bVolume); + + ByteBuffer.wrap(buffer[3]) + .putInt(trak.tkhd.bWidth) + .putInt(trak.tkhd.bHeight); + + return lengthFor(buffer); + } + + private byte[][] make_moov() throws RuntimeException { + int pos = 1; + byte[][] buffer = new byte[2 + (DIMENSIONAL_TWO * infoTracks.length) + (DIMENSIONAL_FIVE * infoTracks.length) + DIMENSIONAL_FIVE + 1][]; + + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 + }; + + for (byte[] buff : make_mvhd()) { + buffer[pos++] = buff; + } + + for (int i = 0; i < infoTracks.length; i++) { + for (byte[] buff : make_trak(i + 1, infoTracks[i].trak)) { + buffer[pos++] = buff; + } + } + + buffer[pos] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x76, 0x65, 0x78 + }; + + ByteBuffer.wrap(buffer[pos++]).putInt((infoTracks.length * DEFAULT_TREX_SIZE) + 8); + + for (int i = 0; i < infoTracks.length; i++) { + for (byte[] buff : make_trex(i + 1, infoTracks[i].trex)) { + buffer[pos++] = buff; + } + } + + // default udta + buffer[pos] = new byte[]{ + 0x00, 0x00, 0x00, 0x5C, 0x75, 0x64, 0x74, 0x61, 0x00, 0x00, 0x00, 0x54, 0x6D, 0x65, 0x74, 0x61, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x72, 0x61, 0x70, 0x70, 0x6C, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x27, 0x69, 0x6C, 0x73, 0x74, 0x00, 0x00, 0x00, + 0x1F, (byte) 0xA9, 0x63, 0x6D, 0x74, 0x00, 0x00, 0x00, 0x17, 0x64, 0x61, 0x74, 0x61, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x00, + 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string + }; + + return lengthFor(buffer); + } + + private byte[][] make_trex(int trackId, Trex trex) { + byte[][] buffer = new byte[][]{ + { + 0x00, 0x00, 0x00, 0x20, 0x74, 0x72, 0x65, 0x78, 0x00, 0x00, 0x00, 0x00 + }, + new byte[20] + }; + + ByteBuffer.wrap(buffer[1]) + .putInt(trackId) + .putInt(trex.defaultSampleDescriptionIndex) + .putInt(trex.defaultSampleDuration) + .putInt(trex.defaultSampleSize) + .putInt(trex.defaultSampleFlags); + + return buffer; + } + + private byte[][] make_tfra(int trackId, List times, List moofOffsets) { + int entryCount = times.size() - 1; + byte[][] buffer = new byte[DIMENSIONAL_TWO][]; + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x74, 0x66, 0x72, 0x61, 0x01, 0x00, 0x00, 0x00 + }; + buffer[1] = new byte[12 + ((16 + TFRA_TTS_DEFAULT.length) * entryCount)]; + + ByteBuffer set = ByteBuffer.wrap(buffer[1]); + set.putInt(trackId); + set.position(8); + set.putInt(entryCount); + + long decodeTime = 0; + + for (int i = 0; i < entryCount; i++) { + decodeTime += times.get(i); + set.putLong(decodeTime); + set.putLong(moofOffsets.get(i)); + set.put(TFRA_TTS_DEFAULT);// default values: traf number/trun number/sample number + } + + return lengthFor(buffer); + } + + private byte[][] make_mfra() { + byte[][] buffer = new byte[2 + (DIMENSIONAL_TWO * infoTracks.length)][]; + buffer[0] = new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x66, 0x72, 0x61 + }; + int pos = 1; + + for (int i = 0; i < infoTracks.length; i++) { + for (byte[] buff : make_tfra(i + 1, chunkTimes.get(i), moofOffsets)) { + buffer[pos++] = buff; + } + } + + buffer[pos] = new byte[]{// mfro + 0x00, 0x00, 0x00, 0x10, 0x6D, 0x66, 0x72, 0x6F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + lengthFor(buffer); + + ByteBuffer set = ByteBuffer.wrap(buffer[pos]); + set.position(12); + set.put(buffer[0], 0, 4); + + return buffer; + + } + + private byte[][] make_sidx(int internalTrackId, long firstOffset) { + List times = chunkTimes.get(internalTrackId); + int count = times.size() - 1;// the first item is ignored (composition time) + + if (count > 65535) { + throw new OutOfMemoryError("to many fragments. sidx limit is 65535, found " + String.valueOf(count)); + } + + byte[][] buffer = new byte[][]{ + new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x73, 0x69, 0x64, 0x78, 0x01, 0x00, 0x00, 0x00 + }, + new byte[calcSidxBodySize(count)] + }; + + lengthFor(buffer); + + ByteBuffer set = ByteBuffer.wrap(buffer[1]); + set.putInt(internalTrackId + 1); + set.putInt(infoTracks[internalTrackId].trak.mdia_mdhd_timeScale); + set.putLong(0); + set.putLong(firstOffset - ByteBuffer.wrap(buffer[0]).getInt()); + set.putInt(0xFFFF & count);// unsigned + + int i = 0; + while (i < count) { + set.putInt(fragSizes.get(i) & 0x7fffffff);// default reference type is 0 + set.putInt(times.get(i + 1)); + set.putInt(0x90000000);// default SAP settings + i++; + } + + return buffer; + } + + private byte[][] make_free(int totalSize) { + return lengthFor(new byte[][]{ + new byte[]{0x00, 0x00, 0x00, 0x00, 0x66, 0x72, 0x65, 0x65}, + new byte[totalSize - 8]// this is waste of RAM + }); + + } + +// + + class TrunExtra { + + ByteBuffer byteBuffer; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java b/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java new file mode 100644 index 000000000..26aaf49a5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/SubtitleConverter.java @@ -0,0 +1,370 @@ +package org.schabi.newpipe.streams; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.SAXException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.text.ParseException; +import java.util.Locale; + +import org.schabi.newpipe.streams.io.SharpStream; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; + +/** + * @author kapodamy + */ +public class SubtitleConverter { + private static final String NEW_LINE = "\r\n"; + + public void dumpTTML(SharpStream in, final SharpStream out, final boolean ignoreEmptyFrames, final boolean detectYoutubeDuplicateLines + ) throws IOException, ParseException, SAXException, ParserConfigurationException, XPathExpressionException { + + final FrameWriter callback = new FrameWriter() { + int frameIndex = 0; + final Charset charset = Charset.forName("utf-8"); + + @Override + public void yield(SubtitleFrame frame) throws IOException { + if (ignoreEmptyFrames && frame.isEmptyText()) { + return; + } + out.write(String.valueOf(frameIndex++).getBytes(charset)); + out.write(NEW_LINE.getBytes(charset)); + out.write(getTime(frame.start, true).getBytes(charset)); + out.write(" --> ".getBytes(charset)); + out.write(getTime(frame.end, true).getBytes(charset)); + out.write(NEW_LINE.getBytes(charset)); + out.write(frame.text.getBytes(charset)); + out.write(NEW_LINE.getBytes(charset)); + out.write(NEW_LINE.getBytes(charset)); + } + }; + + read_xml_based(in, callback, detectYoutubeDuplicateLines, + "tt", "xmlns", "http://www.w3.org/ns/ttml", + new String[]{"timedtext", "head", "wp"}, + new String[]{"body", "div", "p"}, + "begin", "end", true + ); + } + + private void read_xml_based(SharpStream source, FrameWriter callback, boolean detectYoutubeDuplicateLines, + String root, String formatAttr, String formatVersion, String[] cuePath, String[] framePath, + String timeAttr, String durationAttr, boolean hasTimestamp + ) throws IOException, ParseException, SAXException, ParserConfigurationException, XPathExpressionException { + /* + * XML based subtitles parser with BASIC support + * multiple CUE is not supported + * styling is not supported + * tag timestamps (in auto-generated subtitles) are not supported, maybe in the future + * also TimestampTagOption enum is not applicable + * Language parsing is not supported + */ + + byte[] buffer = new byte[source.available()]; + source.read(buffer); + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document xml = builder.parse(new ByteArrayInputStream(buffer)); + + String attr; + + // get the format version or namespace + Element node = xml.getDocumentElement(); + + if (node == null) { + throw new ParseException("Can't get the format version. ¿wrong namespace?", -1); + } else if (!node.getNodeName().equals(root)) { + throw new ParseException("Invalid root", -1); + } + + if (formatAttr.equals("xmlns")) { + if (!node.getNamespaceURI().equals(formatVersion)) { + throw new UnsupportedOperationException("Expected xml namespace: " + formatVersion); + } + } else { + attr = node.getAttributeNS(formatVersion, formatAttr); + if (attr == null) { + throw new ParseException("Can't get the format attribute", -1); + } + if (!attr.equals(formatVersion)) { + throw new ParseException("Invalid format version : " + attr, -1); + } + } + + NodeList node_list; + + int line_break = 0;// Maximum characters per line if present (valid for TranScript v3) + + if (!hasTimestamp) { + node_list = selectNodes(xml, cuePath, formatVersion); + + if (node_list != null) { + // if the subtitle has multiple CUEs, use the highest value + for (int i = 0; i < node_list.getLength(); i++) { + try { + int tmp = Integer.parseInt(((Element) node_list.item(i)).getAttributeNS(formatVersion, "ah")); + if (tmp > line_break) { + line_break = tmp; + } + } catch (Exception err) { + } + } + } + } + + // parse every frame + node_list = selectNodes(xml, framePath, formatVersion); + + if (node_list == null) { + return;// no frames detected + } + + int fs_ff = -1;// first timestamp of first frame + boolean limit_lines = false; + + for (int i = 0; i < node_list.getLength(); i++) { + Element elem = (Element) node_list.item(i); + SubtitleFrame obj = new SubtitleFrame(); + obj.text = elem.getTextContent(); + + attr = elem.getAttribute(timeAttr);// ¡this cant be null! + obj.start = hasTimestamp ? parseTimestamp(attr) : Integer.parseInt(attr); + + attr = elem.getAttribute(durationAttr); + if (obj.text == null || attr == null) { + continue;// normally is a blank line (on auto-generated subtitles) ignore + } + + if (hasTimestamp) { + obj.end = parseTimestamp(attr); + + if (detectYoutubeDuplicateLines) { + if (limit_lines) { + int swap = obj.end; + obj.end = fs_ff; + fs_ff = swap; + } else { + if (fs_ff < 0) { + fs_ff = obj.end; + } else { + if (fs_ff < obj.start) { + limit_lines = true;// the subtitles has duplicated lines + } else { + detectYoutubeDuplicateLines = false; + } + } + } + } + } else { + obj.end = obj.start + Integer.parseInt(attr); + } + + if (/*node.getAttribute("w").equals("1") &&*/line_break > 1 && obj.text.length() > line_break) { + + // implement auto line breaking (once) + StringBuilder text = new StringBuilder(obj.text); + obj.text = null; + + switch (text.charAt(line_break)) { + case ' ': + case '\t': + putBreakAt(line_break, text); + break; + default:// find the word start position + for (int j = line_break - 1; j > 0; j--) { + switch (text.charAt(j)) { + case ' ': + case '\t': + putBreakAt(j, text); + j = -1; + break; + case '\r': + case '\n': + j = -1;// long word, just ignore + break; + } + } + break; + } + + obj.text = text.toString();// set the processed text + } + + callback.yield(obj); + } + } + + private static NodeList selectNodes(Document xml, String[] path, String namespaceUri) throws XPathExpressionException { + Element ref = xml.getDocumentElement(); + + for (int i = 0; i < path.length - 1; i++) { + NodeList nodes = ref.getChildNodes(); + if (nodes.getLength() < 1) { + return null; + } + + Element elem; + for (int j = 0; j < nodes.getLength(); j++) { + if (nodes.item(j).getNodeType() == Node.ELEMENT_NODE) { + elem = (Element) nodes.item(j); + if (elem.getNodeName().equals(path[i]) && elem.getNamespaceURI().equals(namespaceUri)) { + ref = elem; + break; + } + } + } + } + + return ref.getElementsByTagNameNS(namespaceUri, path[path.length - 1]); + } + + private static int parseTimestamp(String multiImpl) throws NumberFormatException, ParseException { + if (multiImpl.length() < 1) { + return 0; + } else if (multiImpl.length() == 1) { + return Integer.parseInt(multiImpl) * 1000;// ¡this must be a number in seconds! + } + + // detect wallclock-time + if (multiImpl.startsWith("wallclock(")) { + throw new UnsupportedOperationException("Parsing wallclock timestamp is not implemented"); + } + + // detect offset-time + if (multiImpl.indexOf(':') < 0) { + int multiplier = 1000; + char metric = multiImpl.charAt(multiImpl.length() - 1); + switch (metric) { + case 'h': + multiplier *= 3600000; + break; + case 'm': + multiplier *= 60000; + break; + case 's': + if (multiImpl.charAt(multiImpl.length() - 2) == 'm') { + multiplier = 1;// ms + } + break; + default: + if (!Character.isDigit(metric)) { + throw new NumberFormatException("Invalid metric suffix found on : " + multiImpl); + } + metric = '\0'; + break; + } + try { + String offset_time = multiImpl; + + if (multiplier == 1) { + offset_time = offset_time.substring(0, offset_time.length() - 2); + } else if (metric != '\0') { + offset_time = offset_time.substring(0, offset_time.length() - 1); + } + + double time_metric_based = Double.parseDouble(offset_time); + if (Math.abs(time_metric_based) <= Double.MAX_VALUE) { + return (int) (time_metric_based * multiplier); + } + } catch (Exception err) { + throw new UnsupportedOperationException("Invalid or not implemented timestamp on: " + multiImpl); + } + } + + // detect clock-time + int time = 0; + String[] units = multiImpl.split(":"); + + if (units.length < 3) { + throw new ParseException("Invalid clock-time timestamp", -1); + } + + time += Integer.parseInt(units[0]) * 3600000;// hours + time += Integer.parseInt(units[1]) * 60000;//minutes + time += Float.parseFloat(units[2]) * 1000f;// seconds and milliseconds (if present) + + // frames and sub-frames are ignored (not implemented) + // time += units[3] * fps; + return time; + } + + private static void putBreakAt(int idx, StringBuilder str) { + // this should be optimized at compile time + + if (NEW_LINE.length() > 1) { + str.delete(idx, idx + 1);// remove after replace + str.insert(idx, NEW_LINE); + } else { + str.setCharAt(idx, NEW_LINE.charAt(0)); + } + } + + private static String getTime(int time, boolean comma) { + // cast every value to integer to avoid auto-round in ToString("00"). + StringBuilder str = new StringBuilder(12); + str.append(numberToString(time / 1000 / 3600, 2));// hours + str.append(':'); + str.append(numberToString(time / 1000 / 60 % 60, 2));// minutes + str.append(':'); + str.append(numberToString(time / 1000 % 60, 2));// seconds + str.append(comma ? ',' : '.'); + str.append(numberToString(time % 1000, 3));// miliseconds + + return str.toString(); + } + + private static String numberToString(int nro, int pad) { + return String.format(Locale.ENGLISH, "%0".concat(String.valueOf(pad)).concat("d"), nro); + } + + + /****************** + * helper classes * + ******************/ + + private interface FrameWriter { + + void yield(SubtitleFrame frame) throws IOException; + } + + private static class SubtitleFrame { + //Java no support unsigned int + + public int end; + public int start; + public String text = ""; + + private boolean isEmptyText() { + if (text == null) { + return true; + } + + for (int i = 0; i < text.length(); i++) { + switch (text.charAt(i)) { + case ' ': + case '\t': + case '\r': + case '\n': + break; + default: + return false; + } + } + + return true; + } + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java b/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java new file mode 100644 index 000000000..86eb5ff4f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/TrackDataChunk.java @@ -0,0 +1,65 @@ +package org.schabi.newpipe.streams; + +import java.io.InputStream; +import java.io.IOException; + +public class TrackDataChunk extends InputStream { + + private final DataReader base; + private int size; + + public TrackDataChunk(DataReader base, int size) { + this.base = base; + this.size = size; + } + + @Override + public int read() throws IOException { + if (size < 1) { + return -1; + } + + int res = base.read(); + + if (res >= 0) { + size--; + } + + return res; + } + + @Override + public int read(byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + count = Math.min(size, count); + int read = base.read(buffer, offset, count); + size -= count; + return read; + } + + @Override + public long skip(long amount) throws IOException { + long res = base.skipBytes(Math.min(amount, size)); + size -= res; + return res; + } + + @Override + public int available() { + return size; + } + + @Override + public void close() { + size = 0; + } + + @Override + public boolean markSupported() { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java new file mode 100644 index 000000000..f61ef14c5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java @@ -0,0 +1,507 @@ +package org.schabi.newpipe.streams; + +import java.io.EOFException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.NoSuchElementException; +import java.util.Objects; + +import org.schabi.newpipe.streams.io.SharpStream; + +/** + * + * @author kapodamy + */ +public class WebMReader { + + // + private final static int ID_EMBL = 0x0A45DFA3; + private final static int ID_EMBLReadVersion = 0x02F7; + private final static int ID_EMBLDocType = 0x0282; + private final static int ID_EMBLDocTypeReadVersion = 0x0285; + + private final static int ID_Segment = 0x08538067; + + private final static int ID_Info = 0x0549A966; + private final static int ID_TimecodeScale = 0x0AD7B1; + private final static int ID_Duration = 0x489; + + private final static int ID_Tracks = 0x0654AE6B; + private final static int ID_TrackEntry = 0x2E; + private final static int ID_TrackNumber = 0x57; + private final static int ID_TrackType = 0x03; + private final static int ID_CodecID = 0x06; + private final static int ID_CodecPrivate = 0x23A2; + private final static int ID_Video = 0x60; + 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_Cluster = 0x0F43B675; + private final static int ID_Timecode = 0x67; + private final static int ID_SimpleBlock = 0x23; +// + + public enum TrackKind { + Audio/*2*/, Video/*1*/, Other + } + + private DataReader stream; + private Segment segment; + private WebMTrack[] tracks; + private int selectedTrack; + private boolean done; + private boolean firstSegment; + + public WebMReader(SharpStream source) { + this.stream = new DataReader(source); + } + + public void parse() throws IOException { + Element elem = readElement(ID_EMBL); + if (!readEbml(elem, 1, 2)) { + throw new UnsupportedOperationException("Unsupported EBML data (WebM)"); + } + ensure(elem); + + elem = untilElement(null, ID_Segment); + if (elem == null) { + throw new IOException("Fragment element not found"); + } + segment = readSegment(elem, 0, true); + tracks = segment.tracks; + selectedTrack = -1; + done = false; + firstSegment = true; + } + + public WebMTrack[] getAvailableTracks() { + return tracks; + } + + public WebMTrack selectTrack(int index) { + selectedTrack = index; + return tracks[index]; + } + + public Segment getNextSegment() throws IOException { + if (done) { + return null; + } + + if (firstSegment && segment != null) { + firstSegment = false; + return segment; + } + + ensure(segment.ref); + + Element elem = untilElement(null, ID_Segment); + if (elem == null) { + done = true; + return null; + } + segment = readSegment(elem, 0, false); + + return segment; + } + + // + private long readNumber(Element parent) throws IOException { + int length = (int) parent.contentSize; + long value = 0; + while (length-- > 0) { + int read = stream.read(); + if (read == -1) { + throw new EOFException(); + } + value = (value << 8) | read; + } + return value; + } + + private String readString(Element parent) throws IOException { + return new String(readBlob(parent), "utf-8"); + } + + private byte[] readBlob(Element parent) throws IOException { + long length = parent.contentSize; + byte[] buffer = new byte[(int) length]; + int read = stream.read(buffer); + if (read < length) { + throw new EOFException(); + } + return buffer; + } + + private long readEncodedNumber() throws IOException { + int value = stream.read(); + + if (value > 0) { + byte size = 1; + int mask = 0x80; + + while (size < 9) { + if ((value & mask) == mask) { + mask = 0xFF; + mask >>= size; + + long number = value & mask; + + for (int i = 1; i < size; i++) { + value = stream.read(); + number <<= 8; + number |= value; + } + + return number; + } + + mask >>= 1; + size++; + } + } + + throw new IOException("Invalid encoded length"); + } + + private Element readElement() throws IOException { + Element elem = new Element(); + elem.offset = stream.position(); + elem.type = (int) readEncodedNumber(); + elem.contentSize = readEncodedNumber(); + elem.size = elem.contentSize + stream.position() - elem.offset; + + return elem; + } + + private Element readElement(int expected) throws IOException { + Element elem = readElement(); + if (expected != 0 && elem.type != expected) { + throw new NoSuchElementException("expected " + elementID(expected) + " found " + elementID(elem.type)); + } + + return elem; + } + + private Element untilElement(Element ref, int... expected) throws IOException { + Element elem; + while (ref == null ? stream.available() : (stream.position() < (ref.offset + ref.size))) { + elem = readElement(); + for (int type : expected) { + if (elem.type == type) { + return elem; + } + } + ensure(elem); + } + + return null; + } + + private String elementID(long type) { + return "0x".concat(Long.toHexString(type)); + } + + private void ensure(Element ref) throws IOException { + long skip = (ref.offset + ref.size) - stream.position(); + + if (skip == 0) { + return; + } else if (skip < 0) { + throw new EOFException(String.format( + "parser go beyond limits of the Element. type=%s offset=%s size=%s position=%s", + elementID(ref.type), ref.offset, ref.size, stream.position() + )); + } + + stream.skipBytes(skip); + } +// + + // + private boolean readEbml(Element ref, int minReadVersion, int minDocTypeVersion) throws IOException { + Element elem = untilElement(ref, ID_EMBLReadVersion); + if (elem == null) { + return false; + } + if (readNumber(elem) > minReadVersion) { + return false; + } + + elem = untilElement(ref, ID_EMBLDocType); + if (elem == null) { + return false; + } + if (!readString(elem).equals("webm")) { + return false; + } + elem = untilElement(ref, ID_EMBLDocTypeReadVersion); + + return elem != null && readNumber(elem) <= minDocTypeVersion; + } + + private Info readInfo(Element ref) throws IOException { + Element elem; + Info info = new Info(); + + while ((elem = untilElement(ref, ID_TimecodeScale, ID_Duration)) != null) { + switch (elem.type) { + case ID_TimecodeScale: + info.timecodeScale = readNumber(elem); + break; + case ID_Duration: + info.duration = readNumber(elem); + break; + } + ensure(elem); + } + + if (info.timecodeScale == 0) { + throw new NoSuchElementException("Element Timecode not found"); + } + + return info; + } + + private Segment readSegment(Element ref, int trackLacingExpected, boolean metadataExpected) throws IOException { + Segment obj = new Segment(ref); + Element elem; + while ((elem = untilElement(ref, ID_Info, ID_Tracks, ID_Cluster)) != null) { + if (elem.type == ID_Cluster) { + obj.currentCluster = elem; + break; + } + switch (elem.type) { + case ID_Info: + obj.info = readInfo(elem); + break; + case ID_Tracks: + obj.tracks = readTracks(elem, trackLacingExpected); + break; + } + ensure(elem); + } + + if (metadataExpected && (obj.info == null || obj.tracks == null)) { + throw new RuntimeException("Cluster element found without Info and/or Tracks element at position " + String.valueOf(ref.offset)); + } + + return obj; + } + + private WebMTrack[] readTracks(Element ref, int lacingExpected) throws IOException { + ArrayList trackEntries = new ArrayList<>(2); + Element elem_trackEntry; + + while ((elem_trackEntry = untilElement(ref, ID_TrackEntry)) != null) { + 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) { + switch (elem.type) { + case ID_TrackNumber: + entry.trackNumber = readNumber(elem); + break; + case ID_TrackType: + entry.trackType = (int)readNumber(elem); + break; + case ID_CodecID: + entry.codecId = readString(elem); + break; + case ID_CodecPrivate: + entry.codecPrivate = readBlob(elem); + break; + case ID_Audio: + case ID_Video: + entry.bMetadata = readBlob(elem); + break; + case ID_DefaultDuration: + entry.defaultDuration = readNumber(elem); + break; + case ID_FlagLacing: + drop = readNumber(elem) != lacingExpected; + break; + default: + System.out.println(); + break; + } + ensure(elem); + } + if (!drop) { + trackEntries.add(entry); + } + ensure(elem_trackEntry); + } + + WebMTrack[] entries = new WebMTrack[trackEntries.size()]; + trackEntries.toArray(entries); + + for (WebMTrack entry : entries) { + switch (entry.trackType) { + case 1: + entry.kind = TrackKind.Video; + break; + case 2: + entry.kind = TrackKind.Audio; + break; + default: + entry.kind = TrackKind.Other; + break; + } + } + + return entries; + } + + 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(); + + if (obj.dataSize < 0) { + throw new IOException(String.format("Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)); + } + return obj; + } + + private Cluster readCluster(Element ref) throws IOException { + Cluster obj = new Cluster(ref); + + Element elem = untilElement(ref, ID_Timecode); + if (elem == null) { + throw new NoSuchElementException("Cluster at " + String.valueOf(ref.offset) + " without Timecode element"); + } + obj.timecode = readNumber(elem); + + return obj; + } +// + + // + class Element { + + int type; + long offset; + long contentSize; + long size; + } + + public class Info { + + public long timecodeScale; + public long duration; + } + + public class WebMTrack { + + public long trackNumber; + protected int trackType; + public String codecId; + public byte[] codecPrivate; + public byte[] bMetadata; + public TrackKind kind; + public long defaultDuration; + } + + public class Segment { + + Segment(Element ref) { + this.ref = ref; + this.firstClusterInSegment = true; + } + + public Info info; + WebMTrack[] tracks; + private Element currentCluster; + private final Element ref; + boolean firstClusterInSegment; + + public Cluster getNextCluster() throws IOException { + if (done) { + return null; + } + if (firstClusterInSegment && segment.currentCluster != null) { + firstClusterInSegment = false; + return readCluster(segment.currentCluster); + } + ensure(segment.currentCluster); + + Element elem = untilElement(segment.ref, ID_Cluster); + if (elem == null) { + return null; + } + + segment.currentCluster = elem; + + return readCluster(segment.currentCluster); + } + } + + public class SimpleBlock { + + public TrackDataChunk data; + + SimpleBlock(Element ref) { + this.ref = ref; + } + + public long trackNumber; + public short relativeTimeCode; + public byte flags; + public long dataSize; + private final Element ref; + + public boolean isKeyframe() { + return (flags & 0x80) == 0x80; + } + } + + public class Cluster { + + Element ref; + SimpleBlock currentSimpleBlock = null; + public long timecode; + + Cluster(Element ref) { + this.ref = ref; + } + + boolean check() { + return stream.position() >= (ref.offset + ref.size); + } + + public SimpleBlock getNextSimpleBlock() throws IOException { + if (check()) { + return null; + } + if (currentSimpleBlock != null) { + ensure(currentSimpleBlock.ref); + } + + while (!check()) { + Element elem = untilElement(ref, ID_SimpleBlock); + if (elem == null) { + return null; + } + + currentSimpleBlock = readSimpleBlock(elem); + if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { + currentSimpleBlock.data = new TrackDataChunk(stream, (int) currentSimpleBlock.dataSize); + return currentSimpleBlock; + } + + ensure(elem); + } + + return null; + } + + } +// +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java new file mode 100644 index 000000000..ea038c607 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -0,0 +1,728 @@ +package org.schabi.newpipe.streams; + +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 java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.util.ArrayList; + +import org.schabi.newpipe.streams.io.SharpStream; + +/** + * + * @author kapodamy + */ +public class WebMWriter { + + private final static int BUFFER_SIZE = 8 * 1024; + private final static int DEFAULT_TIMECODE_SCALE = 1000000; + private final static int INTERV = 100;// 100ms on 1000000us timecode scale + private final static int DEFAULT_CUES_EACH_MS = 5000;// 100ms on 1000000us timecode scale + + private WebMReader.WebMTrack[] infoTracks; + private SharpStream[] sourceTracks; + + private WebMReader[] readers; + + private boolean done = false; + private boolean parsed = false; + + private long written = 0; + + private Segment[] readersSegment; + private Cluster[] readersCluter; + + private int[] predefinedDurations; + + private byte[] outBuffer; + + public WebMWriter(SharpStream... source) { + sourceTracks = source; + readers = new WebMReader[sourceTracks.length]; + infoTracks = new WebMTrack[sourceTracks.length]; + outBuffer = new byte[BUFFER_SIZE]; + } + + public WebMTrack[] getTracksFromSource(int sourceIndex) throws IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (!parsed) { + throw new IllegalStateException("All sources must be parsed first"); + } + + return readers[sourceIndex].getAvailableTracks(); + } + + public void parseSources() throws IOException, IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (parsed) { + throw new IllegalStateException("already parsed"); + } + + try { + for (int i = 0; i < readers.length; i++) { + readers[i] = new WebMReader(sourceTracks[i]); + readers[i].parse(); + } + + } finally { + parsed = true; + } + } + + public void selectTracks(int... trackIndex) throws IOException { + try { + readersSegment = new Segment[readers.length]; + readersCluter = new Cluster[readers.length]; + predefinedDurations = new int[readers.length]; + + for (int i = 0; i < readers.length; i++) { + infoTracks[i] = readers[i].selectTrack(trackIndex[i]); + predefinedDurations[i] = -1; + readersSegment[i] = readers[i].getNextSegment(); + } + } finally { + parsed = true; + } + } + + public long getBytesWritten() { + return written; + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public void close() { + done = true; + parsed = true; + + for (SharpStream src : sourceTracks) { + src.dispose(); + } + + sourceTracks = null; + readers = null; + infoTracks = null; + readersSegment = null; + readersCluter = null; + outBuffer = null; + } + + public void build(SharpStream out) throws IOException, RuntimeException { + if (!out.canRewind()) { + throw new IOException("The output stream must be allow seek"); + } + + makeEBML(out); + + long offsetSegmentSizeSet = written + 5; + long offsetInfoDurationSet = written + 94; + long offsetClusterSet = written + 58; + long offsetCuesSet = written + 75; + + ArrayList listBuffer = new ArrayList<>(4); + + /* segment */ + listBuffer.add(new byte[]{ + 0x18, 0x53, (byte) 0x80, 0x67, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size + }); + + long baseSegmentOffset = written + listBuffer.get(0).length; + + /* seek head */ + listBuffer.add(new byte[]{ + 0x11, 0x4d, (byte) 0x9b, 0x74, (byte) 0xbe, + 0x4d, (byte) 0xbb, (byte) 0x8b, + 0x53, (byte) 0xab, (byte) 0x84, 0x15, 0x49, (byte) 0xa9, 0x66, 0x53, + (byte) 0xac, (byte) 0x81, /*info offset*/ 0x43, + 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, + (byte) 0x84, 0x16, 0x54, (byte) 0xae, 0x6b, 0x53, (byte) 0xac, (byte) 0x81, + /*tracks offset*/ 0x6a, + 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1f, + 0x43, (byte) 0xb6, 0x75, 0x53, (byte) 0xac, (byte) 0x84, /*cluster offset [2]*/ 0x00, 0x00, 0x00, 0x00, + 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1c, 0x53, + (byte) 0xbb, 0x6b, 0x53, (byte) 0xac, (byte) 0x84, /*cues offset [7]*/ 0x00, 0x00, 0x00, 0x00 + }); + + /* info */ + listBuffer.add(new byte[]{ + 0x15, 0x49, (byte) 0xa9, 0x66, (byte) 0xa2, 0x2a, (byte) 0xd7, (byte) 0xb1 + }); + listBuffer.add(encode(DEFAULT_TIMECODE_SCALE, true));// this value MUST NOT exceed 4 bytes + listBuffer.add(new byte[]{0x44, (byte) 0x89, (byte) 0x84, + 0x00, 0x00, 0x00, 0x00,// info.duration + + /* MuxingApp */ + 0x4d, (byte) 0x80, (byte) 0x87, 0x4E, + 0x65, 0x77, 0x50, 0x69, 0x70, 0x65, // "NewPipe" binary string + + /* WritingApp */ + 0x57, 0x41, (byte) 0x87, 0x4E, + 0x65, 0x77, 0x50, 0x69, 0x70, 0x65// "NewPipe" binary string + }); + + /* tracks */ + listBuffer.addAll(makeTracks()); + + for (byte[] buff : listBuffer) { + dump(buff, out); + } + + // reserve space for Cues element, but is a waste of space (actually is 64 KiB) + // TODO: better Cue maker + long cueReservedOffset = written; + dump(new byte[]{(byte) 0xec, 0x20, (byte) 0xff, (byte) 0xfb}, out); + int reserved = (1024 * 63) - 4; + while (reserved > 0) { + int write = Math.min(reserved, outBuffer.length); + out.write(outBuffer, 0, write); + reserved -= write; + written += write; + } + + // Select a track for the cue + int cuesForTrackId = selectTrackForCue(); + long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; + ArrayList keyFrames = new ArrayList<>(32); + + //ArrayList chunks = new ArrayList<>(readers.length); + ArrayList clusterOffsets = new ArrayList<>(32); + ArrayList clusterSizes = new ArrayList<>(32); + + long duration = 0; + int durationFromTrackId = 0; + + byte[] bTimecode = makeTimecode(0); + + int firstClusterOffset = (int) written; + long currentClusterOffset = makeCluster(out, bTimecode, 0, clusterOffsets, clusterSizes); + + long baseTimecode = 0; + long limitTimecode = -1; + int limitTimecodeByTrackId = cuesForTrackId; + + int blockWritten = Integer.MAX_VALUE; + + int newClusterByTrackId = -1; + + while (blockWritten > 0) { + blockWritten = 0; + int i = 0; + while (i < readers.length) { + Block bloq = getNextBlockFrom(i); + if (bloq == null) { + i++; + continue; + } + + if (bloq.data == null) { + blockWritten = 1;// fake block + newClusterByTrackId = i; + i++; + continue; + } + + if (newClusterByTrackId == i) { + limitTimecodeByTrackId = i; + newClusterByTrackId = -1; + baseTimecode = bloq.absoluteTimecode; + limitTimecode = baseTimecode + INTERV; + bTimecode = makeTimecode(baseTimecode); + currentClusterOffset = makeCluster(out, bTimecode, currentClusterOffset, clusterOffsets, clusterSizes); + } + + if (cuesForTrackId == i) { + if ((nextCueTime > -1 && bloq.absoluteTimecode >= nextCueTime) || (nextCueTime < 0 && bloq.isKeyframe())) { + if (nextCueTime > -1) { + nextCueTime += DEFAULT_CUES_EACH_MS; + } + keyFrames.add( + new KeyFrame(baseSegmentOffset, currentClusterOffset - 7, written, bTimecode.length, bloq.absoluteTimecode) + ); + } + } + + writeBlock(out, bloq, baseTimecode); + blockWritten++; + + if (bloq.absoluteTimecode > duration) { + duration = bloq.absoluteTimecode; + durationFromTrackId = bloq.trackNumber; + } + + if (limitTimecode < 0) { + limitTimecode = bloq.absoluteTimecode + INTERV; + continue; + } + + if (bloq.absoluteTimecode >= limitTimecode) { + if (limitTimecodeByTrackId != i) { + limitTimecode += INTERV - (bloq.absoluteTimecode - limitTimecode); + } + i++; + } + } + } + + makeCluster(out, null, currentClusterOffset, null, clusterSizes); + + long segmentSize = written - offsetSegmentSizeSet - 7; + + // final step write offsets and sizes + out.rewind(); + written = 0; + + skipTo(out, offsetSegmentSizeSet); + writeLong(out, segmentSize); + + if (predefinedDurations[durationFromTrackId] > -1) { + duration += predefinedDurations[durationFromTrackId];// this value is full-filled in makeTrackEntry() method + } + skipTo(out, offsetInfoDurationSet); + writeFloat(out, duration); + + firstClusterOffset -= baseSegmentOffset; + skipTo(out, offsetClusterSet); + writeInt(out, firstClusterOffset); + + skipTo(out, cueReservedOffset); + + /* Cue */ + dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); + + for (KeyFrame keyFrame : keyFrames) { + for (byte[] buffer : makeCuePoint(cuesForTrackId, keyFrame)) { + dump(buffer, out); + if (written >= (cueReservedOffset + 65535 - 16)) { + throw new IOException("Too many Cues"); + } + } + } + short cueSize = (short) (written - cueReservedOffset - 7); + + /* EBML Void */ + ByteBuffer voidBuffer = ByteBuffer.allocate(4); + voidBuffer.putShort((short) 0xec20); + voidBuffer.putShort((short) (firstClusterOffset - written - 4)); + dump(voidBuffer.array(), out); + + out.rewind(); + written = 0; + + skipTo(out, offsetCuesSet); + writeInt(out, (int) (cueReservedOffset - baseSegmentOffset)); + + skipTo(out, cueReservedOffset + 5); + writeShort(out, cueSize); + + for (int i = 0; i < clusterSizes.size(); i++) { + skipTo(out, clusterOffsets.get(i)); + byte[] size = ByteBuffer.allocate(4).putInt(clusterSizes.get(i) | 0x200000).array(); + out.write(size, 1, 3); + written += 3; + } + } + + private Block getNextBlockFrom(int internalTrackId) throws IOException { + if (readersSegment[internalTrackId] == null) { + readersSegment[internalTrackId] = readers[internalTrackId].getNextSegment(); + if (readersSegment[internalTrackId] == null) { + return null;// no more blocks in the selected track + } + } + + if (readersCluter[internalTrackId] == null) { + readersCluter[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); + if (readersCluter[internalTrackId] == null) { + readersSegment[internalTrackId] = null; + return getNextBlockFrom(internalTrackId); + } + } + + SimpleBlock res = readersCluter[internalTrackId].getNextSimpleBlock(); + if (res == null) { + readersCluter[internalTrackId] = null; + return new Block();// fake block to indicate the end of the cluster + } + + Block bloq = new Block(); + bloq.data = res.data; + bloq.dataSize = (int) res.dataSize; + bloq.trackNumber = internalTrackId; + bloq.flags = res.flags; + bloq.absoluteTimecode = convertTimecode(res.relativeTimeCode, readersSegment[internalTrackId].info.timecodeScale, DEFAULT_TIMECODE_SCALE); + bloq.absoluteTimecode += readersCluter[internalTrackId].timecode; + + return bloq; + } + + private short convertTimecode(int time, long oldTimeScale, int newTimeScale) { + return (short) (time * (newTimeScale / oldTimeScale)); + } + + private void skipTo(SharpStream stream, long absoluteOffset) throws IOException { + absoluteOffset -= written; + written += absoluteOffset; + stream.skip(absoluteOffset); + } + + private void writeLong(SharpStream stream, long number) throws IOException { + byte[] buffer = ByteBuffer.allocate(DataReader.LONG_SIZE).putLong(number).array(); + stream.write(buffer, 1, buffer.length - 1); + written += buffer.length - 1; + } + + private void writeFloat(SharpStream stream, float number) throws IOException { + byte[] buffer = ByteBuffer.allocate(DataReader.FLOAT_SIZE).putFloat(number).array(); + dump(buffer, stream); + } + + private void writeShort(SharpStream stream, short number) throws IOException { + byte[] buffer = ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort(number).array(); + dump(buffer, stream); + } + + private void writeInt(SharpStream stream, int number) throws IOException { + byte[] buffer = ByteBuffer.allocate(DataReader.INTEGER_SIZE).putInt(number).array(); + dump(buffer, stream); + } + + private void writeBlock(SharpStream stream, Block bloq, long clusterTimecode) throws IOException { + long relativeTimeCode = bloq.absoluteTimecode - clusterTimecode; + + if (relativeTimeCode < Short.MIN_VALUE || relativeTimeCode > Short.MAX_VALUE) { + throw new IndexOutOfBoundsException("SimpleBlock timecode overflow."); + } + + ArrayList listBuffer = new ArrayList<>(5); + listBuffer.add(new byte[]{(byte) 0xa3}); + listBuffer.add(null);// block size + listBuffer.add(encode(bloq.trackNumber + 1, false)); + listBuffer.add(ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort((short) relativeTimeCode).array()); + listBuffer.add(new byte[]{bloq.flags}); + + int blockSize = bloq.dataSize; + for (int i = 2; i < listBuffer.size(); i++) { + blockSize += listBuffer.get(i).length; + } + listBuffer.set(1, encode(blockSize, false)); + + for (byte[] buff : listBuffer) { + dump(buff, stream); + } + + int read; + while ((read = bloq.data.read(outBuffer)) > 0) { + stream.write(outBuffer, 0, read); + written += read; + } + } + + private byte[] makeTimecode(long timecode) { + ByteBuffer buffer = ByteBuffer.allocate(9); + buffer.put((byte) 0xe7); + buffer.put(encode(timecode, true)); + + byte[] res = new byte[buffer.position()]; + System.arraycopy(buffer.array(), 0, res, 0, res.length); + + return res; + } + + private long makeCluster(SharpStream stream, byte[] bTimecode, long startOffset, ArrayList clusterOffsets, ArrayList clusterSizes) throws IOException { + if (startOffset > 0) { + clusterSizes.add((int) (written - startOffset));// size for last offset + } + + if (clusterOffsets != null) { + /* cluster */ + dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); + clusterOffsets.add(written);// warning: max cluster size is 256 MiB + dump(new byte[]{0x20, 0x00, 0x00}, stream); + + startOffset = written;// size for the this cluster + + dump(bTimecode, stream); + + return startOffset; + } + + return -1; + } + + private void makeEBML(SharpStream stream) throws IOException { + // deafult values + dump(new byte[]{ + 0x1A, 0x45, (byte) 0xDF, (byte) 0xA3, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0x42, (byte) 0x86, (byte) 0x81, 0x01, + 0x42, (byte) 0xF7, (byte) 0x81, 0x01, 0x42, (byte) 0xF2, (byte) 0x81, 0x04, + 0x42, (byte) 0xF3, (byte) 0x81, 0x08, 0x42, (byte) 0x82, (byte) 0x84, 0x77, + 0x65, 0x62, 0x6D, 0x42, (byte) 0x87, (byte) 0x81, 0x02, + 0x42, (byte) 0x85, (byte) 0x81, 0x02 + }, stream); + } + + private ArrayList makeTracks() { + ArrayList buffer = new ArrayList<>(1); + buffer.add(new byte[]{0x16, 0x54, (byte) 0xae, 0x6b}); + buffer.add(null); + + for (int i = 0; i < infoTracks.length; i++) { + buffer.addAll(makeTrackEntry(i, infoTracks[i])); + } + + return lengthFor(buffer); + } + + private ArrayList makeTrackEntry(int internalTrackId, WebMTrack track) { + byte[] id = encode(internalTrackId + 1, true); + ArrayList buffer = new ArrayList<>(12); + + /* track */ + buffer.add(new byte[]{(byte) 0xae}); + buffer.add(null); + + /* track number */ + buffer.add(new byte[]{(byte) 0xd7}); + buffer.add(id); + + /* track uid */ + buffer.add(new byte[]{0x73, (byte) 0xc5}); + buffer.add(id); + + /* flag lacing */ + buffer.add(new byte[]{(byte) 0x9c, (byte) 0x81, 0x00}); + + /* lang */ + buffer.add(new byte[]{0x22, (byte) 0xb5, (byte) 0x9c, (byte) 0x83, 0x75, 0x6e, 0x64}); + + /* codec id */ + buffer.add(new byte[]{(byte) 0x86}); + buffer.addAll(encode(track.codecId)); + + /* type */ + buffer.add(new byte[]{(byte) 0x83}); + buffer.add(encode(track.trackType, true)); + + /* default duration */ + if (track.defaultDuration != 0) { + predefinedDurations[internalTrackId] = (int) Math.ceil(track.defaultDuration / (float) DEFAULT_TIMECODE_SCALE); + buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83}); + buffer.add(encode(track.defaultDuration, true)); + } + + /* audio/video */ + if ((track.trackType == 1 || track.trackType == 2) && valid(track.bMetadata)) { + buffer.add(new byte[]{(byte) (track.trackType == 1 ? 0xe0 : 0xe1)}); + buffer.add(encode(track.bMetadata.length, false)); + buffer.add(track.bMetadata); + } + + /* codec private*/ + if (valid(track.codecPrivate)) { + buffer.add(new byte[]{0x63, (byte) 0xa2}); + buffer.add(encode(track.codecPrivate.length, false)); + buffer.add(track.codecPrivate); + } + + return lengthFor(buffer); + + } + + private ArrayList makeCuePoint(int internalTrackId, KeyFrame keyFrame) { + ArrayList buffer = new ArrayList<>(5); + + /* CuePoint */ + buffer.add(new byte[]{(byte) 0xbb}); + buffer.add(null); + + /* CueTime */ + buffer.add(new byte[]{(byte) 0xb3}); + buffer.add(encode(keyFrame.atTimecode, true)); + + /* CueTrackPosition */ + buffer.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); + + return lengthFor(buffer); + } + + private ArrayList makeCueTrackPosition(int internalTrackId, KeyFrame keyFrame) { + ArrayList buffer = new ArrayList<>(8); + + /* CueTrackPositions */ + buffer.add(new byte[]{(byte) 0xb7}); + buffer.add(null); + + /* CueTrack */ + buffer.add(new byte[]{(byte) 0xf7}); + buffer.add(encode(internalTrackId + 1, true)); + + /* CueClusterPosition */ + buffer.add(new byte[]{(byte) 0xf1}); + buffer.add(encode(keyFrame.atCluster, true)); + + /* CueRelativePosition */ + if (keyFrame.atBlock > 0) { + buffer.add(new byte[]{(byte) 0xf0}); + buffer.add(encode(keyFrame.atBlock, true)); + } + + return lengthFor(buffer); + } + + private void dump(byte[] buffer, SharpStream stream) throws IOException { + stream.write(buffer); + written += buffer.length; + } + + private ArrayList lengthFor(ArrayList buffer) { + long size = 0; + for (int i = 2; i < buffer.size(); i++) { + size += buffer.get(i).length; + } + buffer.set(1, encode(size, false)); + return buffer; + } + + private byte[] encode(long number, boolean withLength) { + int length = -1; + for (int i = 1; i <= 7; i++) { + if (number < Math.pow(2, 7 * i)) { + length = i; + break; + } + } + + if (length < 1) { + throw new ArithmeticException("Can't encode a number of bigger than 7 bytes"); + } + + if (number == (Math.pow(2, 7 * length)) - 1) { + length++; + } + + int offset = withLength ? 1 : 0; + byte[] buffer = new byte[offset + length]; + long marker = (long) Math.floor((length - 1) / 8); + + for (int i = length - 1, mul = 1; i >= 0; i--, mul *= 0x100) { + long b = (long) Math.floor(number / mul); + if (!withLength && i == marker) { + b = b | (0x80 >> (length - 1)); + } + buffer[offset + i] = (byte) b; + } + + if (withLength) { + buffer[0] = (byte) (0x80 | length); + } + + return buffer; + } + + private ArrayList encode(String value) { + byte[] str; + try { + str = value.getBytes("utf-8"); + } catch (UnsupportedEncodingException err) { + str = value.getBytes(); + } + + ArrayList buffer = new ArrayList<>(2); + buffer.add(encode(str.length, false)); + buffer.add(str); + + return buffer; + } + + private boolean valid(byte[] buffer) { + return buffer != null && buffer.length > 0; + } + + private int selectTrackForCue() { + int i = 0; + int videoTracks = 0; + int audioTracks = 0; + + for (; i < infoTracks.length; i++) { + switch (infoTracks[i].trackType) { + case 1: + videoTracks++; + break; + case 2: + audioTracks++; + break; + } + } + + int kind; + if (audioTracks == infoTracks.length) { + kind = 2; + } else if (videoTracks == infoTracks.length) { + kind = 1; + } else if (videoTracks > 0) { + kind = 1; + } else if (audioTracks > 0) { + kind = 2; + } else { + return 0; + } + + // TODO: in the adove code, find and select the shortest track for the desired kind + for (i = 0; i < infoTracks.length; i++) { + if (kind == infoTracks[i].trackType) { + return i; + } + } + + return 0; + } + + class KeyFrame { + + KeyFrame(long segment, long cluster, long block, int bTimecodeLength, long timecode) { + atCluster = cluster - segment; + if ((block - bTimecodeLength) > cluster) { + atBlock = (int) (block - cluster); + } + atTimecode = timecode; + } + + long atCluster; + int atBlock; + long atTimecode; + } + + class Block { + + InputStream data; + int trackNumber; + byte flags; + int dataSize; + long absoluteTimecode; + + boolean isKeyframe() { + return (flags & 0x80) == 0x80; + } + + @Override + public String toString() { + return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, (flags & 0x80) == 0x80, absoluteTimecode); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java new file mode 100644 index 000000000..48bea06f6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java @@ -0,0 +1,47 @@ +package org.schabi.newpipe.streams.io; + +import java.io.IOException; + +/** + * based c# + */ +public abstract class SharpStream { + + public abstract int read() throws IOException; + + public abstract int read(byte buffer[]) throws IOException; + + public abstract int read(byte buffer[], int offset, int count) throws IOException; + + public abstract long skip(long amount) throws IOException; + + + public abstract int available(); + + public abstract void rewind() throws IOException; + + + public abstract void dispose(); + + public abstract boolean isDisposed(); + + + public abstract boolean canRewind(); + + public abstract boolean canRead(); + + public abstract boolean canWrite(); + + + public abstract void write(byte value) throws IOException; + + public abstract void write(byte[] buffer) throws IOException; + + public abstract void write(byte[] buffer, int offset, int count) throws IOException; + + public abstract void flush() throws IOException; + + public void setLength(long length) throws IOException { + throw new IOException("Not implemented"); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java index 3c5f16929..6a398a8a2 100644 --- a/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/AnimationUtils.java @@ -25,6 +25,7 @@ import android.animation.ArgbEvaluator; import android.animation.ValueAnimator; import android.content.res.ColorStateList; import android.support.annotation.ColorInt; +import android.support.annotation.FloatRange; import android.support.v4.view.ViewCompat; import android.support.v4.view.animation.FastOutSlowInInterpolator; import android.util.Log; @@ -363,4 +364,24 @@ public class AnimationUtils { }).start(); } } + + public static void slideUp(final View view, + long duration, + long delay, + @FloatRange(from = 0.0f, to = 1.0f) float translationPercent) { + int translationY = (int) (view.getResources().getDisplayMetrics().heightPixels * + (translationPercent)); + + view.animate().setListener(null).cancel(); + view.setAlpha(0f); + view.setTranslationY(translationY); + view.setVisibility(View.VISIBLE); + view.animate() + .alpha(1f) + .translationY(0) + .setStartDelay(delay) + .setDuration(duration) + .setInterpolator(new FastOutSlowInInterpolator()) + .start(); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index e328ad23e..3f6d82b9f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -69,8 +69,7 @@ public final class ExtractorHelper { public static Single searchFor(final int serviceId, final String searchString, final List contentFilter, - final String sortFilter, - final String contentCountry) { + final String sortFilter) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getInfo(NewPipe.getService(serviceId), @@ -83,8 +82,7 @@ public final class ExtractorHelper { final String searchString, final List contentFilter, final String sortFilter, - final String pageUrl, - final String contentCountry) { + final String pageUrl) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getMoreItems(NewPipe.getService(serviceId), @@ -96,8 +94,7 @@ public final class ExtractorHelper { } public static Single> suggestionsFor(final int serviceId, - final String query, - final String contentCountry) { + final String query) { checkServiceId(serviceId); return Single.fromCallable(() -> NewPipe.getService(serviceId) @@ -126,7 +123,7 @@ public final class ExtractorHelper { final String nextStreamsUrl) { checkServiceId(serviceId); return Single.fromCallable(() -> - ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl, NewPipe.getLocalization())); + ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextStreamsUrl)); } public static Single getCommentsInfo(final int serviceId, @@ -163,19 +160,17 @@ public final class ExtractorHelper { public static Single getKioskInfo(final int serviceId, final String url, - final String contentCountry, boolean forceLoad) { return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, Single.fromCallable(() -> - KioskInfo.getInfo(NewPipe.getService(serviceId), url, contentCountry))); + KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single getMoreKioskItems(final int serviceId, - final String url, - final String nextStreamsUrl, - final String contentCountry) { + final String url, + final String nextStreamsUrl) { return Single.fromCallable(() -> KioskInfo.getMoreItems(NewPipe.getService(serviceId), - url, nextStreamsUrl, contentCountry)); + url, nextStreamsUrl)); } /*////////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index 871d0578f..8fc423837 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -110,7 +110,8 @@ public final class ListHelper { : context.getString(R.string.best_resolution_key); String maxResolution = getResolutionLimit(context); - if (maxResolution != null && compareVideoStreamResolution(maxResolution, resolution) < 1){ + if (maxResolution != null && (resolution.equals(context.getString(R.string.best_resolution_key)) + || compareVideoStreamResolution(maxResolution, resolution) < 1)) { resolution = maxResolution; } return resolution; diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index c1e5c9ed4..eed1a8ae2 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -69,10 +69,23 @@ public class Localization { return stringBuilder.toString(); } + public static org.schabi.newpipe.extractor.utils.Localization getPreferredExtractorLocal(Context context) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + + String languageCode = sp.getString(context.getString(R.string.content_language_key), + context.getString(R.string.default_language_value)); + + String countryCode = sp.getString(context.getString(R.string.content_country_key), + context.getString(R.string.default_country_value)); + + return new org.schabi.newpipe.extractor.utils.Localization(countryCode, languageCode); + } + public static Locale getPreferredLocale(Context context) { SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - String languageCode = sp.getString(context.getString(R.string.search_language_key), context.getString(R.string.default_language_value)); + String languageCode = sp.getString(context.getString(R.string.content_language_key), + context.getString(R.string.default_language_value)); try { if (languageCode.length() == 2) { 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 562dd7e49..4b93600ce 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -50,7 +50,6 @@ import org.schabi.newpipe.player.MainVideoPlayer; import org.schabi.newpipe.player.PopupVideoPlayer; import org.schabi.newpipe.player.PopupVideoPlayerActivity; import org.schabi.newpipe.player.VideoPlayer; -import org.schabi.newpipe.player.old.PlayVideoActivity; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.settings.SettingsActivity; @@ -118,26 +117,6 @@ public class NavigationHelper { context.startActivity(playerIntent); } - public static void playOnOldVideoPlayer(Context context, StreamInfo info) { - ArrayList videoStreamsList = new ArrayList<>(ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false)); - int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList); - - if (index == -1) { - Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); - return; - } - - VideoStream videoStream = videoStreamsList.get(index); - Intent intent = new Intent(context, PlayVideoActivity.class) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(PlayVideoActivity.VIDEO_TITLE, info.getName()) - .putExtra(PlayVideoActivity.STREAM_URL, videoStream.getUrl()) - .putExtra(PlayVideoActivity.VIDEO_URL, info.getUrl()) - .putExtra(PlayVideoActivity.START_POSITION, info.getStartPosition()); - - context.startActivity(intent); - } - public static void playOnPopupPlayer(final Context context, final PlayQueue queue) { if (!PermissionHelper.isPopupEnabled(context)) { PermissionHelper.showPopupEnablementToast(context); diff --git a/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java index c81100703..6de663c13 100644 --- a/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java +++ b/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java @@ -4,11 +4,15 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; public class RelatedStreamInfo extends ListInfo { + private StreamInfoItem nextStream; public RelatedStreamInfo(int serviceId, ListLinkHandler listUrlIdHandler, String name) { super(serviceId, listUrlIdHandler, name); @@ -17,7 +21,21 @@ public class RelatedStreamInfo extends ListInfo { public static RelatedStreamInfo getInfo(StreamInfo info) { ListLinkHandler handler = new ListLinkHandler(info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null); RelatedStreamInfo relatedStreamInfo = new RelatedStreamInfo(info.getServiceId(), handler, info.getName()); - relatedStreamInfo.setRelatedItems(info.getRelatedStreams()); - return relatedStreamInfo; + List streams = new ArrayList<>(); + if(info.getNextVideo() != null){ + streams.add(info.getNextVideo()); + } + streams.addAll(info.getRelatedStreams()); + relatedStreamInfo.setRelatedItems(streams); + relatedStreamInfo.setNextStream(info.getNextVideo()); + return relatedStreamInfo; + } + + public StreamInfoItem getNextStream() { + return nextStream; + } + + public void setNextStream(StreamInfoItem nextStream) { + this.nextStream = nextStream; } } diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java new file mode 100644 index 000000000..a5d3ea3eb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -0,0 +1,66 @@ +package org.schabi.newpipe.util; + +import android.support.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.VideoStream; +import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; + +import java.util.List; + +public class SecondaryStreamHelper { + private final int position; + private final StreamSizeWrapper streams; + + public SecondaryStreamHelper(StreamSizeWrapper streams, T selectedStream) { + this.streams = streams; + this.position = streams.getStreamsList().indexOf(selectedStream); + if (this.position < 0) throw new RuntimeException("selected stream not found"); + } + + public T getStream() { + return streams.getStreamsList().get(position); + } + + public long getSizeInBytes() { + return streams.getSizeInBytes(position); + } + + /** + * find the correct audio stream for the desired video stream + * + * @param audioStreams list of audio streams + * @param videoStream desired video ONLY stream + * @return selected audio stream or null if a candidate was not found + */ + public static AudioStream getAudioStreamFor(@NonNull List audioStreams, @NonNull VideoStream videoStream) { + // TODO: check if m4v and m4a selected streams are DASH compliant + switch (videoStream.getFormat()) { + case WEBM: + case MPEG_4: + break; + default: + return null; + } + + boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + + for (AudioStream audio : audioStreams) { + if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { + return audio; + } + } + + // 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)) { + return audio; + } + } + + return null; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index e100a447b..eb106f91d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; import android.content.Context; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -13,6 +14,7 @@ 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.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.io.Serializable; @@ -28,26 +30,34 @@ 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 { +public class StreamItemAdapter extends BaseAdapter { private final Context context; private final StreamSizeWrapper streamsWrapper; - private final boolean showIconNoAudio; + private final SparseArray> secondaryStreams; - public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, boolean showIconNoAudio) { + public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, SparseArray> secondaryStreams) { this.context = context; this.streamsWrapper = streamsWrapper; - this.showIconNoAudio = showIconNoAudio; + this.secondaryStreams = secondaryStreams; + } + + public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, boolean showIconNoAudio) { + this(context, streamsWrapper, showIconNoAudio ? new SparseArray<>() : null); } public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper) { - this(context, streamsWrapper, false); + this(context, streamsWrapper, null); } public List getAll() { return streamsWrapper.getStreamsList(); } + public SparseArray> getAllSecondary() { + return secondaryStreams; + } + @Override public int getCount() { return streamsWrapper.getStreamsList().size(); @@ -89,29 +99,46 @@ public class StreamItemAdapter extends BaseAdapter { String qualityString; if (stream instanceof VideoStream) { - qualityString = ((VideoStream) stream).getResolution(); + VideoStream videoStream = ((VideoStream) stream); + qualityString = videoStream.getResolution(); - if (!showIconNoAudio) { - woSoundIconVisibility = View.GONE; - } else if (((VideoStream) stream).isVideoOnly()) { - woSoundIconVisibility = View.VISIBLE; - } else if (isDropdownItem) { - woSoundIconVisibility = View.INVISIBLE; + if (secondaryStreams != null) { + if (videoStream.isVideoOnly()) { + woSoundIconVisibility = secondaryStreams.get(position) == null ? View.VISIBLE : View.INVISIBLE; + } else if (isDropdownItem) { + woSoundIconVisibility = View.INVISIBLE; + } } } else if (stream instanceof AudioStream) { qualityString = ((AudioStream) stream).getAverageBitrate() + "kbps"; + } else if (stream instanceof SubtitlesStream) { + qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); + if (((SubtitlesStream) stream).isAutoGenerated()) { + qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; + } } else { qualityString = stream.getFormat().getSuffix(); } if (streamsWrapper.getSizeInBytes(position) > 0) { - sizeView.setText(streamsWrapper.getFormattedSize(position)); + SecondaryStreamHelper secondary = secondaryStreams == null ? null : secondaryStreams.get(position); + if (secondary != null) { + long size = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); + sizeView.setText(Utility.formatBytes(size)); + } else { + sizeView.setText(streamsWrapper.getFormattedSize(position)); + } sizeView.setVisibility(View.VISIBLE); } else { sizeView.setVisibility(View.GONE); } - formatNameView.setText(stream.getFormat().getName()); + if (stream instanceof SubtitlesStream) { + formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); + } else { + formatNameView.setText(stream.getFormat().getName()); + } + qualityView.setText(qualityString); woSoundIconView.setVisibility(woSoundIconVisibility); @@ -122,15 +149,17 @@ public class StreamItemAdapter extends BaseAdapter { * 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 static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>(Collections.emptyList(), null); private final List streamsList; private final long[] streamSizes; + private final String unknownSize; - public StreamSizeWrapper(List streamsList) { + public StreamSizeWrapper(List streamsList, Context context) { this.streamsList = streamsList; this.streamSizes = new long[streamsList.size()]; + this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); - for (int i = 0; i < streamSizes.length; i++) streamSizes[i] = -1; + for (int i = 0; i < streamSizes.length; i++) streamSizes[i] = -2; } /** @@ -143,7 +172,7 @@ public class StreamItemAdapter extends BaseAdapter { final Callable fetchAndSet = () -> { boolean hasChanged = false; for (X stream : streamsWrapper.getStreamsList()) { - if (streamsWrapper.getSizeInBytes(stream) > 0) { + if (streamsWrapper.getSizeInBytes(stream) > -2) { continue; } @@ -173,11 +202,18 @@ public class StreamItemAdapter extends BaseAdapter { } public String getFormattedSize(int streamIndex) { - return Utility.formatBytes(getSizeInBytes(streamIndex)); + return formatSize(getSizeInBytes(streamIndex)); } public String getFormattedSize(T stream) { - return Utility.formatBytes(getSizeInBytes(stream)); + return formatSize(getSizeInBytes(stream)); + } + + private String formatSize(long size) { + if (size > -1) { + return Utility.formatBytes(size); + } + return unknownSize; } public void setSize(int streamIndex, long sizeInBytes) { @@ -193,4 +229,4 @@ public class StreamItemAdapter extends BaseAdapter { return (StreamSizeWrapper) EMPTY; } } -} \ No newline at end of file +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java deleted file mode 100644 index 2a8a9e129..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadDataSource.java +++ /dev/null @@ -1,40 +0,0 @@ -package us.shandian.giga.get; - -import java.util.List; - -/** - * Provides access to the storage of {@link DownloadMission}s - */ -public interface DownloadDataSource { - - /** - * Load all missions - * - * @return a list of download missions - */ - List loadMissions(); - - /** - * Add a download mission to the storage - * - * @param downloadMission the download mission to add - * @return the identifier of the mission - */ - void addMission(DownloadMission downloadMission); - - /** - * Update a download mission which exists in the storage - * - * @param downloadMission the download mission to update - * @throws IllegalArgumentException if the mission was not added to storage - */ - void updateMission(DownloadMission downloadMission); - - - /** - * Delete a download mission - * - * @param downloadMission the mission to delete - */ - void deleteMission(DownloadMission downloadMission); -} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java new file mode 100644 index 000000000..ce7ae267c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -0,0 +1,186 @@ +package us.shandian.giga.get; + +import android.support.annotation.NonNull; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.RandomAccessFile; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; + +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipe.BuildConfig.DEBUG; + +public class DownloadInitializer extends Thread { + private final static String TAG = "DownloadInitializer"; + final static int mId = 0; + + private DownloadMission mMission; + private HttpURLConnection mConn; + + DownloadInitializer(@NonNull DownloadMission mission) { + mMission = mission; + mConn = null; + } + + @Override + public void run() { + if (mMission.current > 0) mMission.resetState(); + + int retryCount = 0; + while (true) { + try { + mMission.currentThreadCount = mMission.threadCount; + + mConn = mMission.openConnection(mId, -1, -1); + mMission.establishConnection(mId, mConn); + + if (!mMission.running || Thread.interrupted()) return; + + mMission.length = Utility.getContentLength(mConn); + + + if (mMission.length == 0) { + mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); + return; + } + + // check for dynamic generated content + if (mMission.length == -1 && mConn.getResponseCode() == 200) { + mMission.blocks = 0; + mMission.length = 0; + mMission.fallback = true; + mMission.unknownLength = true; + mMission.currentThreadCount = 1; + + if (DEBUG) { + Log.d(TAG, "falling back (unknown length)"); + } + } else { + // Open again + mConn = mMission.openConnection(mId, mMission.length - 10, mMission.length); + mMission.establishConnection(mId, mConn); + + if (!mMission.running || Thread.interrupted()) return; + + synchronized (mMission.blockState) { + if (mConn.getResponseCode() == 206) { + if (mMission.currentThreadCount > 1) { + mMission.blocks = mMission.length / DownloadMission.BLOCK_SIZE; + + if (mMission.currentThreadCount > mMission.blocks) { + mMission.currentThreadCount = (int) mMission.blocks; + } + if (mMission.currentThreadCount <= 0) { + mMission.currentThreadCount = 1; + } + if (mMission.blocks * DownloadMission.BLOCK_SIZE < mMission.length) { + mMission.blocks++; + } + } else { + // if one thread is solicited don't calculate blocks, is useless + mMission.blocks = 1; + mMission.fallback = true; + mMission.unknownLength = false; + } + + if (DEBUG) { + Log.d(TAG, "http response code = " + mConn.getResponseCode()); + } + } else { + // Fallback to single thread + mMission.blocks = 0; + mMission.fallback = true; + mMission.unknownLength = false; + mMission.currentThreadCount = 1; + + if (DEBUG) { + Log.d(TAG, "falling back due http response code = " + mConn.getResponseCode()); + } + } + + for (long i = 0; i < mMission.currentThreadCount; i++) { + mMission.threadBlockPositions.add(i); + mMission.threadBytePositions.add(0L); + } + } + + if (!mMission.running || Thread.interrupted()) return; + } + + File file; + if (mMission.current == 0) { + file = new File(mMission.location); + if (!Utility.mkdir(file, true)) { + mMission.notifyError(DownloadMission.ERROR_PATH_CREATION, null); + return; + } + + file = new File(file, mMission.name); + + // if the name is used by another process, delete it + if (file.exists() && !file.isFile() && !file.delete()) { + mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); + return; + } + + if (!file.exists() && !file.createNewFile()) { + mMission.notifyError(DownloadMission.ERROR_FILE_CREATION, null); + return; + } + } else { + file = new File(mMission.location, mMission.name); + } + + RandomAccessFile af = new RandomAccessFile(file, "rw"); + af.setLength(mMission.offsets[mMission.current] + mMission.length); + af.seek(mMission.offsets[mMission.current]); + af.close(); + + if (!mMission.running || Thread.interrupted()) return; + + mMission.running = false; + break; + } catch (InterruptedIOException | ClosedByInterruptException e) { + return; + } catch (Exception e) { + if (!mMission.running) return; + + if (e instanceof IOException && e.getMessage().contains("Permission denied")) { + mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); + return; + } + + if (retryCount++ > mMission.maxRetry) { + Log.e(TAG, "initializer failed", e); + mMission.running = false; + mMission.notifyError(e); + return; + } + + Log.e(TAG, "initializer failed, retrying", e); + } + } + + // hide marquee in the progress bar + mMission.done++; + + mMission.start(); + } + + @Override + public void interrupt() { + super.interrupt(); + + if (mConn != null) { + try { + mConn.disconnect(); + } catch (Exception e) { + // nothing to do + } + } + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadManager.java b/app/src/main/java/us/shandian/giga/get/DownloadManager.java deleted file mode 100644 index 45beb5563..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadManager.java +++ /dev/null @@ -1,53 +0,0 @@ -package us.shandian.giga.get; - -public interface DownloadManager { - int BLOCK_SIZE = 512 * 1024; - - /** - * Start a new download mission - * - * @param url the url to download - * @param location the location - * @param name the name of the file to create - * @param isAudio true if the download is an audio file - * @param threads the number of threads maximal used to download chunks of the file. @return the identifier of the mission. - */ - int startMission(String url, String location, String name, boolean isAudio, int threads); - - /** - * Resume the execution of a download mission. - * - * @param id the identifier of the mission to resume. - */ - void resumeMission(int id); - - /** - * Pause the execution of a download mission. - * - * @param id the identifier of the mission to pause. - */ - void pauseMission(int id); - - /** - * Deletes the mission from the downloaded list but keeps the downloaded file. - * - * @param id The mission identifier - */ - void deleteMission(int id); - - /** - * Get the download mission by its identifier - * - * @param id the identifier of the download mission - * @return the download mission or null if the mission doesn't exist - */ - DownloadMission getMission(int id); - - /** - * Get the number of download missions. - * - * @return the number of download missions. - */ - int getCount(); - -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java b/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java deleted file mode 100755 index a377d861c..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadManagerImpl.java +++ /dev/null @@ -1,395 +0,0 @@ -package us.shandian.giga.get; - -import android.content.Context; -import android.content.Intent; -import android.os.Handler; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.util.Log; - -import org.schabi.newpipe.download.ExtSDDownloadFailedActivity; - -import java.io.File; -import java.io.FilenameFilter; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; - -import us.shandian.giga.util.Utility; - -import static org.schabi.newpipe.BuildConfig.DEBUG; - -public class DownloadManagerImpl implements DownloadManager { - private static final String TAG = DownloadManagerImpl.class.getSimpleName(); - private final DownloadDataSource mDownloadDataSource; - - private final ArrayList mMissions = new ArrayList<>(); - @NonNull - private final Context context; - - /** - * Create a new instance - * - * @param searchLocations the directories to search for unfinished downloads - * @param downloadDataSource the data source for finished downloads - */ - public DownloadManagerImpl(Collection searchLocations, DownloadDataSource downloadDataSource) { - mDownloadDataSource = downloadDataSource; - this.context = null; - loadMissions(searchLocations); - } - - public DownloadManagerImpl(Collection searchLocations, DownloadDataSource downloadDataSource, Context context) { - mDownloadDataSource = downloadDataSource; - this.context = context; - loadMissions(searchLocations); - } - - @Override - public int startMission(String url, String location, String name, boolean isAudio, int threads) { - DownloadMission existingMission = getMissionByLocation(location, name); - if (existingMission != null) { - // Already downloaded or downloading - if (existingMission.finished) { - // Overwrite mission - deleteMission(mMissions.indexOf(existingMission)); - } else { - // Rename file (?) - try { - name = generateUniqueName(location, name); - } catch (Exception e) { - Log.e(TAG, "Unable to generate unique name", e); - name = System.currentTimeMillis() + name; - Log.i(TAG, "Using " + name); - } - } - } - - DownloadMission mission = new DownloadMission(name, url, location); - mission.timestamp = System.currentTimeMillis(); - mission.threadCount = threads; - mission.addListener(new MissionListener(mission)); - new Initializer(mission).start(); - return insertMission(mission); - } - - @Override - public void resumeMission(int i) { - DownloadMission d = getMission(i); - if (!d.running && d.errCode == -1) { - d.start(); - } - } - - @Override - public void pauseMission(int i) { - DownloadMission d = getMission(i); - if (d.running) { - d.pause(); - } - } - - @Override - public void deleteMission(int i) { - DownloadMission mission = getMission(i); - if (mission.finished) { - mDownloadDataSource.deleteMission(mission); - } - mission.delete(); - mMissions.remove(i); - } - - private void loadMissions(Iterable searchLocations) { - mMissions.clear(); - loadFinishedMissions(); - for (String location : searchLocations) { - loadMissions(location); - } - - } - - /** - * Sort a list of mission by its timestamp. Oldest first - * @param missions the missions to sort - */ - static void sortByTimestamp(List missions) { - Collections.sort(missions, new Comparator() { - @Override - public int compare(DownloadMission o1, DownloadMission o2) { - return Long.compare(o1.timestamp, o2.timestamp); - } - }); - } - - /** - * Loads finished missions from the data source - */ - private void loadFinishedMissions() { - List finishedMissions = mDownloadDataSource.loadMissions(); - if (finishedMissions == null) { - finishedMissions = new ArrayList<>(); - } - // Ensure its sorted - sortByTimestamp(finishedMissions); - - mMissions.ensureCapacity(mMissions.size() + finishedMissions.size()); - for (DownloadMission mission : finishedMissions) { - File downloadedFile = mission.getDownloadedFile(); - if (!downloadedFile.isFile()) { - if (DEBUG) { - Log.d(TAG, "downloaded file removed: " + downloadedFile.getAbsolutePath()); - } - mDownloadDataSource.deleteMission(mission); - } else { - mission.length = downloadedFile.length(); - mission.finished = true; - mission.running = false; - mMissions.add(mission); - } - } - } - - private void loadMissions(String location) { - - File f = new File(location); - - if (f.exists() && f.isDirectory()) { - File[] subs = f.listFiles(); - - if (subs == null) { - Log.e(TAG, "listFiles() returned null"); - return; - } - - for (File sub : subs) { - if (sub.isFile() && sub.getName().endsWith(".giga")) { - DownloadMission mis = Utility.readFromFile(sub.getAbsolutePath()); - if (mis != null) { - if (mis.finished) { - if (!sub.delete()) { - Log.w(TAG, "Unable to delete .giga file: " + sub.getPath()); - } - continue; - } - - mis.running = false; - mis.recovered = true; - insertMission(mis); - } - } - } - } - } - - @Override - public DownloadMission getMission(int i) { - return mMissions.get(i); - } - - @Override - public int getCount() { - return mMissions.size(); - } - - private int insertMission(DownloadMission mission) { - int i = -1; - - DownloadMission m = null; - - if (mMissions.size() > 0) { - do { - m = mMissions.get(++i); - } while (m.timestamp > mission.timestamp && i < mMissions.size() - 1); - - //if (i > 0) i--; - } else { - i = 0; - } - - mMissions.add(i, mission); - - return i; - } - - /** - * Get a mission by its location and name - * - * @param location the location - * @param name the name - * @return the mission or null if no such mission exists - */ - private - @Nullable - DownloadMission getMissionByLocation(String location, String name) { - for (DownloadMission mission : mMissions) { - if (location.equals(mission.location) && name.equals(mission.name)) { - return mission; - } - } - return null; - } - - /** - * Splits the filename into name and extension - *

- * Dots are ignored if they appear: not at all, at the beginning of the file, - * at the end of the file - * - * @param name the name to split - * @return a string array with a length of 2 containing the name and the extension - */ - private static String[] splitName(String name) { - int dotIndex = name.lastIndexOf('.'); - if (dotIndex <= 0 || (dotIndex == name.length() - 1)) { - return new String[]{name, ""}; - } else { - return new String[]{name.substring(0, dotIndex), name.substring(dotIndex + 1)}; - } - } - - /** - * Generates a unique file name. - *

- * e.g. "myname (1).txt" if the name "myname.txt" exists. - * - * @param location the location (to check for existing files) - * @param name the name of the file - * @return the unique file name - * @throws IllegalArgumentException if the location is not a directory - * @throws SecurityException if the location is not readable - */ - private static String generateUniqueName(String location, String name) { - if (location == null) throw new NullPointerException("location is null"); - if (name == null) throw new NullPointerException("name is null"); - File destination = new File(location); - if (!destination.isDirectory()) { - throw new IllegalArgumentException("location is not a directory: " + location); - } - final String[] nameParts = splitName(name); - String[] existingName = destination.list(new FilenameFilter() { - @Override - public boolean accept(File dir, String name) { - return name.startsWith(nameParts[0]); - } - }); - Arrays.sort(existingName); - String newName; - int downloadIndex = 0; - do { - newName = nameParts[0] + " (" + downloadIndex + ")." + nameParts[1]; - ++downloadIndex; - if (downloadIndex == 1000) { // Probably an error on our side - throw new RuntimeException("Too many existing files"); - } - } while (Arrays.binarySearch(existingName, newName) >= 0); - return newName; - } - - private class Initializer extends Thread { - private final DownloadMission mission; - private final Handler handler; - - public Initializer(DownloadMission mission) { - this.mission = mission; - this.handler = new Handler(); - } - - @Override - public void run() { - try { - URL url = new URL(mission.url); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - mission.length = conn.getContentLength(); - - if (mission.length <= 0) { - mission.errCode = DownloadMission.ERROR_SERVER_UNSUPPORTED; - //mission.notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); - return; - } - - // Open again - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestProperty("Range", "bytes=" + (mission.length - 10) + "-" + mission.length); - - if (conn.getResponseCode() != 206) { - // Fallback to single thread if no partial content support - mission.fallback = true; - - if (DEBUG) { - Log.d(TAG, "falling back"); - } - } - - if (DEBUG) { - Log.d(TAG, "response = " + conn.getResponseCode()); - } - - mission.blocks = mission.length / BLOCK_SIZE; - - if (mission.threadCount > mission.blocks) { - mission.threadCount = (int) mission.blocks; - } - - if (mission.threadCount <= 0) { - mission.threadCount = 1; - } - - if (mission.blocks * BLOCK_SIZE < mission.length) { - mission.blocks++; - } - - - new File(mission.location).mkdirs(); - new File(mission.location + "/" + mission.name).createNewFile(); - RandomAccessFile af = new RandomAccessFile(mission.location + "/" + mission.name, "rw"); - af.setLength(mission.length); - af.close(); - - mission.start(); - } catch (IOException ie) { - if(context == null) throw new RuntimeException(ie); - - if(ie.getMessage().contains("Permission denied")) { - handler.post(() -> - context.startActivity(new Intent(context, ExtSDDownloadFailedActivity.class))); - } else throw new RuntimeException(ie); - } catch (Exception e) { - // TODO Notify - throw new RuntimeException(e); - } - } - } - - /** - * Waits for mission to finish to add it to the {@link #mDownloadDataSource} - */ - private class MissionListener implements DownloadMission.MissionListener { - private final DownloadMission mMission; - - private MissionListener(DownloadMission mission) { - if (mission == null) throw new NullPointerException("mission is null"); - // Could the mission be passed in onFinish()? - mMission = mission; - } - - @Override - public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { - } - - @Override - public void onFinish(DownloadMission downloadMission) { - mDownloadDataSource.addMission(mMission); - } - - @Override - public void onError(DownloadMission downloadMission, int errCode) { - } - } -} 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 79c4baf05..c25d517f1 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,102 +1,170 @@ package us.shandian.giga.get; import android.os.Handler; -import android.os.Looper; +import android.os.Message; import android.util.Log; import java.io.File; -import java.io.ObjectInputStream; -import java.io.Serializable; -import java.lang.ref.WeakReference; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.HashMap; -import java.util.Iterator; import java.util.List; -import java.util.Map; +import javax.net.ssl.SSLException; + +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; -public class DownloadMission implements Serializable { - private static final long serialVersionUID = 0L; +public class DownloadMission extends Mission { + private static final long serialVersionUID = 3L;// last bump: 8 november 2018 - private static final String TAG = DownloadMission.class.getSimpleName(); + static final int BUFFER_SIZE = 64 * 1024; + final static int BLOCK_SIZE = 512 * 1024; - public interface MissionListener { - HashMap handlerStore = new HashMap<>(); + private static final String TAG = "DownloadMission"; - void onProgressUpdate(DownloadMission downloadMission, long done, long total); - - void onFinish(DownloadMission downloadMission); - - void onError(DownloadMission downloadMission, int errCode); - } - - public static final int ERROR_SERVER_UNSUPPORTED = 206; - public static final int ERROR_UNKNOWN = 233; + public static final int ERROR_NOTHING = -1; + public static final int ERROR_PATH_CREATION = 1000; + public static final int ERROR_FILE_CREATION = 1001; + public static final int ERROR_UNKNOWN_EXCEPTION = 1002; + public static final int ERROR_PERMISSION_DENIED = 1003; + public static final int ERROR_SSL_EXCEPTION = 1004; + public static final int ERROR_UNKNOWN_HOST = 1005; + public static final int ERROR_CONNECT_HOST = 1006; + public static final int ERROR_POSTPROCESSING_FAILED = 1007; + public static final int ERROR_HTTP_NO_CONTENT = 204; + public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206; /** - * The filename + * The urls of the file to download */ - public String name; + public String[] urls; /** - * The url of the file to download + * Number of blocks the size of {@link DownloadMission#BLOCK_SIZE} */ - public String url; - - /** - * The directory to store the download - */ - public String location; - - /** - * Number of blocks the size of {@link DownloadManager#BLOCK_SIZE} - */ - public long blocks; - - /** - * Number of bytes - */ - public long length; + long blocks = -1; /** * Number of bytes downloaded */ public long done; + + /** + * Indicates a file generated dynamically on the web server + */ + public boolean unknownLength; + + /** + * offset in the file where the data should be written + */ + public long[] offsets; + + /** + * The post-processing algorithm arguments + */ + public String[] postprocessingArgs; + + /** + * The post-processing algorithm name + */ + public String postprocessingName; + + /** + * Indicates if the post-processing algorithm is actually running, used to detect corrupt downloads + */ + public boolean postprocessingRunning; + + /** + * Indicate if the post-processing algorithm works on the same file + */ + public boolean postprocessingThis; + + /** + * The current resource to download {@code urls[current]} + */ + public int current; + + /** + * Metadata where the mission state is saved + */ + public File metadata; + + /** + * maximum attempts + */ + public int maxRetry; + + /** + * Approximated final length, this represent the sum of all resources sizes + */ + public long nearLength; + public int threadCount = 3; - public int finishCount; - private final List threadPositions = new ArrayList<>(); - public final Map blockState = new HashMap<>(); - public boolean running; - public boolean finished; - public boolean fallback; - public int errCode = -1; - public long timestamp; + boolean fallback; + private int finishCount; + public transient boolean running; + public transient boolean enqueued = true; + public int errCode = ERROR_NOTHING; + + public transient Exception errObject = null; public transient boolean recovered; - - private transient ArrayList> mListeners = new ArrayList<>(); + public transient Handler mHandler; private transient boolean mWritingToFile; - private static final int NO_IDENTIFIER = -1; + @SuppressWarnings("UseSparseArrays")// LongSparseArray is not serializable + final HashMap blockState = new HashMap<>(); + final List threadBlockPositions = new ArrayList<>(); + final List threadBytePositions = new ArrayList<>(); + + private transient boolean deleted; + int currentThreadCount; + private transient Thread[] threads = new Thread[0]; + private transient Thread init = null; + + + protected DownloadMission() { - public DownloadMission() { } - public DownloadMission(String name, String url, String location) { + public DownloadMission(String url, String name, String location, char kind) { + this(new String[]{url}, name, location, kind, null, null); + } + + public DownloadMission(String[] urls, String name, String location, char kind, String postprocessingName, String[] postprocessingArgs) { if (name == null) throw new NullPointerException("name is null"); if (name.isEmpty()) throw new IllegalArgumentException("name is empty"); - if (url == null) throw new NullPointerException("url is null"); - if (url.isEmpty()) throw new IllegalArgumentException("url is empty"); + if (urls == null) throw new NullPointerException("urls is null"); + if (urls.length < 1) throw new IllegalArgumentException("urls is empty"); if (location == null) throw new NullPointerException("location is null"); if (location.isEmpty()) throw new IllegalArgumentException("location is empty"); - this.url = url; + this.urls = urls; this.name = name; this.location = location; - } + this.kind = kind; + this.offsets = new long[urls.length]; + if (postprocessingName != null) { + Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, null); + this.postprocessingThis = algorithm.worksOnSameFile; + this.offsets[0] = algorithm.recommendedReserve; + this.postprocessingName = postprocessingName; + this.postprocessingArgs = postprocessingArgs; + } else { + if (DEBUG && urls.length > 1) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); + } + } + } private void checkBlock(long block) { if (block < 0 || block >= blocks) { @@ -110,12 +178,12 @@ public class DownloadMission implements Serializable { * @param block the block identifier * @return true if the block is reserved and false if otherwise */ - public boolean isBlockPreserved(long block) { + boolean isBlockPreserved(long block) { checkBlock(block); return blockState.containsKey(block) ? blockState.get(block) : false; } - public void preserveBlock(long block) { + void preserveBlock(long block) { checkBlock(block); synchronized (blockState) { blockState.put(block, true); @@ -123,125 +191,211 @@ public class DownloadMission implements Serializable { } /** - * Set the download position of the file + * Set the block of the file * * @param threadId the identifier of the thread - * @param position the download position of the thread + * @param position the block of the thread */ - public void setPosition(int threadId, long position) { - threadPositions.set(threadId, position); + void setBlockPosition(int threadId, long position) { + threadBlockPositions.set(threadId, position); } /** - * Get the position of a thread + * Get the block of a file * * @param threadId the identifier of the thread - * @return the position for the thread + * @return the block for the thread */ - public long getPosition(int threadId) { - return threadPositions.get(threadId); + long getBlockPosition(int threadId) { + return threadBlockPositions.get(threadId); } - public synchronized void notifyProgress(long deltaLen) { + /** + * Save the position of the desired thread + * + * @param threadId the identifier of the thread + * @param position the relative position in bytes or zero + */ + void setThreadBytePosition(int threadId, long position) { + threadBytePositions.set(threadId, position); + } + + /** + * Get position inside of the thread, where thread will be resumed + * + * @param threadId the identifier of the thread + * @return the relative position in bytes or zero + */ + long getThreadBytePosition(int threadId) { + return threadBytePositions.get(threadId); + } + + /** + * Open connection + * + * @param threadId id of the calling thread, used only for debug + * @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 { + URL url = new URL(urls[current]); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setInstanceFollowRedirects(true); + + 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; + } + + /** + * @param threadId id of the calling thread + * @param conn Opens and establish the communication + * @throws IOException if an error occurred connecting to the server. + * @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); + } + + switch (statusCode) { + case 204: + case 205: + case 207: + throw new HttpError(conn.getResponseCode()); + case 416: + return;// let the download thread handle this error + default: + if (statusCode < 200 || statusCode > 299) { + throw new HttpError(statusCode); + } + } + + } + + + private void notify(int what) { + Message m = new Message(); + m.what = what; + m.obj = this; + + mHandler.sendMessage(m); + } + + 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 (done != length) { - writeThisToFile(); + if (done != length && !deleted && !mWritingToFile) { + mWritingToFile = true; + runAsync(-2, this::writeThisToFile); } - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - if (listener != null) { - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onProgressUpdate(DownloadMission.this, done, length); - } - }); - } + notify(DownloadManagerService.MESSAGE_PROGRESS); + } + + synchronized void notifyError(Exception err) { + Log.e(TAG, "notifyError()", err); + + if (err instanceof FileNotFoundException) { + notifyError(ERROR_FILE_CREATION, null); + } else if (err instanceof SSLException) { + notifyError(ERROR_SSL_EXCEPTION, null); + } else if (err instanceof HttpError) { + notifyError(((HttpError) err).statusCode, null); + } else if (err instanceof ConnectException) { + notifyError(ERROR_CONNECT_HOST, null); + } else if (err instanceof UnknownHostException) { + notifyError(ERROR_UNKNOWN_HOST, null); + } else { + notifyError(ERROR_UNKNOWN_EXCEPTION, err); } } - /** - * Called by a download thread when it finished. - */ - public synchronized void notifyFinished() { - if (errCode > 0) return; + synchronized void notifyError(int code, Exception err) { + Log.e(TAG, "notifyError() code = " + code, err); + + errCode = code; + errObject = err; + + pause(); + + notify(DownloadManagerService.MESSAGE_ERROR); + } + + synchronized void notifyFinished() { + if (errCode > ERROR_NOTHING) return; finishCount++; - if (finishCount == threadCount) { - onFinish(); + if (finishCount == currentThreadCount) { + if (errCode > ERROR_NOTHING) 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; + } + + current++; + unknownLength = false; + + if (!doPostprocessing()) return; + + running = false; + deleteThisFromFile(); + + notify(DownloadManagerService.MESSAGE_FINISHED); } } - /** - * Called when all parts are downloaded - */ - private void onFinish() { - if (errCode > 0) return; - + private void notifyPostProcessing(boolean processing) { if (DEBUG) { - Log.d(TAG, "onFinish"); + Log.d(TAG, (processing ? "enter" : "exit") + " postprocessing on " + location + File.separator + name); } - running = false; - finished = true; - - deleteThisFromFile(); - - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - if (listener != null) { - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onFinish(DownloadMission.this); - } - }); + synchronized (blockState) { + if (!processing) { + postprocessingName = null; + postprocessingArgs = null; } - } - } - public synchronized void notifyError(int err) { - errCode = err; - - writeThisToFile(); - - for (WeakReference ref : mListeners) { - final MissionListener listener = ref.get(); - MissionListener.handlerStore.get(listener).post(new Runnable() { - @Override - public void run() { - listener.onError(DownloadMission.this, errCode); - } - }); - } - } - - public synchronized void addListener(MissionListener listener) { - Handler handler = new Handler(Looper.getMainLooper()); - MissionListener.handlerStore.put(listener, handler); - mListeners.add(new WeakReference<>(listener)); - } - - public synchronized void removeListener(MissionListener listener) { - for (Iterator> iterator = mListeners.iterator(); - iterator.hasNext(); ) { - WeakReference weakRef = iterator.next(); - if (listener != null && listener == weakRef.get()) { - iterator.remove(); - } + // don't return without fully write the current state + postprocessingRunning = processing; + Utility.writeToFile(metadata, DownloadMission.this); } } @@ -249,92 +403,257 @@ public class DownloadMission implements Serializable { * Start downloading with multiple threads. */ public void start() { - if (!running && !finished) { - running = true; + if (running || current >= urls.length) return; - if (!fallback) { - for (int i = 0; i < threadCount; i++) { - if (threadPositions.size() <= i && !recovered) { - threadPositions.add((long) i); - } - new Thread(new DownloadRunnable(this, i)).start(); - } - } else { - // In fallback mode, resuming is not supported. - threadCount = 1; + // ensure that the previous state is completely paused. + joinForThread(init); + for (Thread thread : threads) joinForThread(thread); + + enqueued = false; + running = true; + errCode = ERROR_NOTHING; + + if (blocks < 0) { + initializer(); + return; + } + + init = null; + + if (threads.length < 1) { + threads = new Thread[currentThreadCount]; + } + + if (fallback) { + if (unknownLength) { done = 0; - blocks = 0; - new Thread(new DownloadRunnableFallback(this)).start(); + length = 0; + } + + threads[0] = runAsync(1, new DownloadRunnableFallback(this)); + } else { + for (int i = 0; i < currentThreadCount; i++) { + threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); } } } - public void pause() { - if (running) { - running = false; - recovered = true; + /** + * Pause the mission, does not affect the blocks that are being downloaded. + */ + public synchronized void pause() { + if (!running) return; - // TODO: Notify & Write state to info file - // if (err) + running = false; + recovered = true; + enqueued = false; + + if (postprocessingRunning) { + if (DEBUG) { + Log.w(TAG, "pause during post-processing is not applicable."); + } + return; } + + if (init != null && init.isAlive()) { + init.interrupt(); + synchronized (blockState) { + resetState(); + } + return; + } + + if (DEBUG && blocks == 0) { + Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); + } + + if (threads == null || Thread.currentThread().isInterrupted()) { + writeThisToFile(); + return; + } + + // wait for all threads are suspended before save the state + runAsync(-1, () -> { + try { + for (Thread thread : threads) { + if (thread.isAlive()) { + thread.interrupt(); + thread.join(5000); + } + } + } catch (Exception e) { + // nothing to do + } finally { + writeThisToFile(); + } + }); } /** * Removes the file and the meta file */ - public void delete() { - deleteThisFromFile(); - new File(location, name).delete(); + @Override + public boolean delete() { + deleted = true; + boolean res = deleteThisFromFile(); + if (!super.delete()) res = false; + return res; + } + + void resetState() { + done = 0; + blocks = -1; + errCode = ERROR_NOTHING; + fallback = false; + unknownLength = false; + finishCount = 0; + threadBlockPositions.clear(); + threadBytePositions.clear(); + blockState.clear(); + threads = new Thread[0]; + + Utility.writeToFile(metadata, DownloadMission.this); + } + + private void initializer() { + init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); + } /** * Write this {@link DownloadMission} to the meta file asynchronously * if no thread is already running. */ - public void writeThisToFile() { - if (!mWritingToFile) { - mWritingToFile = true; - new Thread() { - @Override - public void run() { - doWriteThisToFile(); - mWritingToFile = false; - } - }.start(); - } - } - - /** - * Write this {@link DownloadMission} to the meta file. - */ - private void doWriteThisToFile() { + private void writeThisToFile() { synchronized (blockState) { - Utility.writeToFile(getMetaFilename(), this); + if (deleted) return; + Utility.writeToFile(metadata, DownloadMission.this); + } + mWritingToFile = false; + } + + public boolean isFinished() { + return current >= urls.length && postprocessingName == null; + } + + public long getLength() { + long calculated; + if (postprocessingRunning) { + calculated = length; + } else { + calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; + } + + calculated -= offsets[0];// don't count reserved space + + return calculated > nearLength ? calculated : nearLength; + } + + private boolean doPostprocessing() { + if (postprocessingName == null) return true; + + try { + notifyPostProcessing(true); + notifyProgress(0); + + Thread.currentThread().setName("[" + TAG + "] post-processing = " + postprocessingName + " filename = " + name); + + Postprocessing algorithm = Postprocessing.getAlgorithm(postprocessingName, this); + algorithm.run(); + } catch (Exception err) { + StringBuilder args = new StringBuilder(" "); + if (postprocessingArgs != null) { + for (String arg : postprocessingArgs) { + args.append(", "); + args.append(arg); + } + args.delete(0, 1); + } + Log.e(TAG, String.format("Post-processing failed. algorithm = %s args = [%s]", postprocessingName, args), err); + + notifyError(ERROR_POSTPROCESSING_FAILED, err); + return false; + } finally { + notifyPostProcessing(false); + } + + if (errCode != ERROR_NOTHING) notify(DownloadManagerService.MESSAGE_ERROR); + + return errCode == ERROR_NOTHING; + } + + private boolean deleteThisFromFile() { + synchronized (blockState) { + return metadata.delete(); } } - private void readObject(ObjectInputStream inputStream) - throws java.io.IOException, ClassNotFoundException - { - inputStream.defaultReadObject(); - mListeners = new ArrayList<>(); - } - - private void deleteThisFromFile() { - new File(getMetaFilename()).delete(); + /** + * run a new thread + * + * @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)); } /** - * Get the path of the meta file + * run a new thread * - * @return the path to the meta file + * @param id id of new thread (used for debugging only) + * @param who the Thread whose {@code run} method is invoked when this thread is started + * @return the passed thread */ - private String getMetaFilename() { - return location + "/" + name + ".giga"; + private Thread runAsync(int id, Thread who) { + // known thread ids: + // -2: state saving by notifyProgress() method + // -1: wait for saving the state by pause() method + // 0: initializer + // >=1: any download thread + + if (DEBUG) { + who.setName(String.format("%s[%s] %s", TAG, id, name)); + } + + who.start(); + + return who; } - public File getDownloadedFile() { - return new File(location, name); + private void joinForThread(Thread thread) { + if (thread == null || !thread.isAlive()) return; + if (thread == Thread.currentThread()) return; + + if (DEBUG) { + Log.w(TAG, "a thread is !still alive!: " + thread.getName()); + } + + // still alive, this should not happen. + // Possible reasons: + // slow device + // the user is spamming start/pause buttons + // start() method called quickly after pause() + + try { + thread.join(10000); + } catch (InterruptedException e) { + Log.d(TAG, "timeout on join : " + thread.getName()); + throw new RuntimeException("A thread is still running:\n" + thread.getName()); + } } + + static class HttpError extends Exception { + int statusCode; + + HttpError(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public String getMessage() { + return "HTTP " + String.valueOf(statusCode); + } + } } 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 6ad8626c3..244fbd47a 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -2,9 +2,11 @@ package us.shandian.giga.get; import android.util.Log; +import java.io.FileNotFoundException; +import java.io.InputStream; import java.io.RandomAccessFile; import java.net.HttpURLConnection; -import java.net.URL; +import java.nio.channels.ClosedByInterruptException; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -12,142 +14,166 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; * Runnable to download blocks of a file until the file is completely downloaded, * an error occurs or the process is stopped. */ -public class DownloadRunnable implements Runnable { +public class DownloadRunnable extends Thread { private static final String TAG = DownloadRunnable.class.getSimpleName(); private final DownloadMission mMission; private final int mId; - public DownloadRunnable(DownloadMission mission, int id) { + private HttpURLConnection mConn; + + DownloadRunnable(DownloadMission mission, int id) { if (mission == null) throw new NullPointerException("mission is null"); mMission = mission; mId = id; + mConn = null; } @Override public void run() { boolean retry = mMission.recovered; - long position = mMission.getPosition(mId); + long blockPosition = mMission.getBlockPosition(mId); + int retryCount = 0; if (DEBUG) { - Log.d(TAG, mId + ":default pos " + position); + Log.d(TAG, mId + ":default pos " + blockPosition); Log.d(TAG, mId + ":recovered: " + mMission.recovered); } - while (mMission.errCode == -1 && mMission.running && position < mMission.blocks) { + RandomAccessFile f; + InputStream is = null; - if (Thread.currentThread().isInterrupted()) { - mMission.pause(); - return; - } + try { + f = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + } catch (FileNotFoundException e) { + mMission.notifyError(e);// this never should happen + return; + } + + while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING && blockPosition < mMission.blocks) { if (DEBUG && retry) { - Log.d(TAG, mId + ":retry is true. Resuming at " + position); + Log.d(TAG, mId + ":retry is true. Resuming at " + blockPosition); } // Wait for an unblocked position - while (!retry && position < mMission.blocks && mMission.isBlockPreserved(position)) { + while (!retry && blockPosition < mMission.blocks && mMission.isBlockPreserved(blockPosition)) { if (DEBUG) { - Log.d(TAG, mId + ":position " + position + " preserved, passing"); + Log.d(TAG, mId + ":position " + blockPosition + " preserved, passing"); } - position++; + blockPosition++; } retry = false; - if (position >= mMission.blocks) { + if (blockPosition >= mMission.blocks) { break; } if (DEBUG) { - Log.d(TAG, mId + ":preserving position " + position); + Log.d(TAG, mId + ":preserving position " + blockPosition); } - mMission.preserveBlock(position); - mMission.setPosition(mId, position); + mMission.preserveBlock(blockPosition); + mMission.setBlockPosition(mId, blockPosition); - long start = position * DownloadManager.BLOCK_SIZE; - long end = start + DownloadManager.BLOCK_SIZE - 1; + long start = blockPosition * DownloadMission.BLOCK_SIZE; + long end = start + DownloadMission.BLOCK_SIZE - 1; + long offset = mMission.getThreadBytePosition(mId); + + start += offset; if (end >= mMission.length) { end = mMission.length - 1; } - HttpURLConnection conn = null; - - int total = 0; + long total = 0; try { - URL url = new URL(mMission.url); - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestProperty("Range", "bytes=" + start + "-" + end); + mConn = mMission.openConnection(mId, start, end); + mMission.establishConnection(mId, mConn); - if (DEBUG) { - Log.d(TAG, mId + ":" + conn.getRequestProperty("Range")); - Log.d(TAG, mId + ":Content-Length=" + conn.getContentLength() + " Code:" + conn.getResponseCode()); + // check if the download can be resumed + if (mConn.getResponseCode() == 416 && offset > 0) { + retryCount--; + throw new DownloadMission.HttpError(416); } - // A server may be ignoring the range request - if (conn.getResponseCode() != 206) { - mMission.errCode = DownloadMission.ERROR_SERVER_UNSUPPORTED; - notifyError(); + // The server may be ignoring the range request + if (mConn.getResponseCode() != 206) { + mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); if (DEBUG) { - Log.e(TAG, mId + ":Unsupported " + conn.getResponseCode()); + Log.e(TAG, mId + ":Unsupported " + mConn.getResponseCode()); } break; } - RandomAccessFile f = new RandomAccessFile(mMission.location + "/" + mMission.name, "rw"); - f.seek(start); - java.io.InputStream ipt = conn.getInputStream(); - byte[] buf = new byte[64*1024]; + f.seek(mMission.offsets[mMission.current] + start); - while (start < end && mMission.running) { - int len = ipt.read(buf, 0, buf.length); + is = mConn.getInputStream(); - if (len == -1) { - break; - } else { - start += len; - total += len; - f.write(buf, 0, len); - notifyProgress(len); - } + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; + int len; + + while (start < end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { + f.write(buf, 0, len); + start += len; + total += len; + mMission.notifyProgress(len); } if (DEBUG && mMission.running) { - Log.d(TAG, mId + ":position " + position + " finished, total length " + total); + Log.d(TAG, mId + ":position " + blockPosition + " finished, " + total + " bytes downloaded"); } - f.close(); - ipt.close(); + if (mMission.running) + mMission.setThreadBytePosition(mId, 0L);// clear byte position for next block + else + mMission.setThreadBytePosition(mId, total);// download paused, save progress for this block - // TODO We should save progress for each thread } catch (Exception e) { - // TODO Retry count limit & notify error - retry = true; + mMission.setThreadBytePosition(mId, total); - notifyProgress(-total); + if (!mMission.running || e instanceof ClosedByInterruptException) break; + + if (retryCount++ >= mMission.maxRetry) { + mMission.notifyError(e); + break; + } if (DEBUG) { - Log.d(TAG, mId + ":position " + position + " retrying", e); + Log.d(TAG, mId + ":position " + blockPosition + " retrying due exception", e); } + + retry = true; } } + try { + if (is != null) is.close(); + } catch (Exception err) { + // nothing to do + } + + try { + f.close(); + } catch (Exception err) { + // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? + } + if (DEBUG) { - Log.d(TAG, "thread " + mId + " exited main loop"); + Log.d(TAG, "thread " + mId + " exited from main download loop"); } - if (mMission.errCode == -1 && mMission.running) { + if (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running) { if (DEBUG) { Log.d(TAG, "no error has happened, notifying"); } - notifyFinished(); + mMission.notifyFinished(); } if (DEBUG && !mMission.running) { @@ -155,22 +181,15 @@ public class DownloadRunnable implements Runnable { } } - private void notifyProgress(final long len) { - synchronized (mMission) { - mMission.notifyProgress(len); + @Override + public void interrupt() { + super.interrupt(); + + try { + if (mConn != null) mConn.disconnect(); + } catch (Exception e) { + // nothing to do } } - private void notifyError() { - synchronized (mMission) { - mMission.notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); - mMission.pause(); - } - } - - private void notifyFinished() { - synchronized (mMission) { - mMission.notifyFinished(); - } - } } 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 f24139910..4bcaeaf85 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -1,74 +1,139 @@ package us.shandian.giga.get; -import java.io.BufferedInputStream; +import android.annotation.SuppressLint; +import android.support.annotation.NonNull; +import android.util.Log; + +import java.io.IOException; +import java.io.InputStream; import java.io.RandomAccessFile; import java.net.HttpURLConnection; -import java.net.URL; +import java.nio.channels.ClosedByInterruptException; + + +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipe.BuildConfig.DEBUG; + +/** + * Single-threaded fallback mode + */ +public class DownloadRunnableFallback extends Thread { + private static final String TAG = "DownloadRunnableFallback"; -// Single-threaded fallback mode -public class DownloadRunnableFallback implements Runnable { private final DownloadMission mMission; - //private int mId; + private final int mId = 1; - public DownloadRunnableFallback(DownloadMission mission) { - if (mission == null) throw new NullPointerException("mission is null"); - //mId = id; + private int mRetryCount = 0; + private InputStream mIs; + private RandomAccessFile mF; + private HttpURLConnection mConn; + + DownloadRunnableFallback(@NonNull DownloadMission mission) { mMission = mission; + mIs = null; + mF = null; + mConn = null; + } + + private void dispose() { + try { + if (mIs != null) mIs.close(); + } catch (IOException e) { + // nothing to do + } + + try { + if (mF != null) mF.close(); + } catch (IOException e) { + // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? + } } @Override + @SuppressLint("LongLogTag") public void run() { - try { - URL url = new URL(mMission.url); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + boolean done; - if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) { - notifyError(DownloadMission.ERROR_SERVER_UNSUPPORTED); - } else { - RandomAccessFile f = new RandomAccessFile(mMission.location + "/" + mMission.name, "rw"); - f.seek(0); - BufferedInputStream ipt = new BufferedInputStream(conn.getInputStream()); - byte[] buf = new byte[512]; - int len = 0; + long start = 0; - while ((len = ipt.read(buf, 0, 512)) != -1 && mMission.running) { - f.write(buf, 0, len); - notifyProgress(len); - - if (Thread.interrupted()) { - break; - } - - } - - f.close(); - ipt.close(); + if (!mMission.unknownLength) { + start = mMission.getThreadBytePosition(0); + if (DEBUG && start > 0) { + Log.i(TAG, "Resuming a single-thread download at " + start); } + } + + try { + long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; + + mConn = mMission.openConnection(mId, rangeStart, -1); + mMission.establishConnection(mId, mConn); + + // check if the download can be resumed + if (mConn.getResponseCode() == 416 && start > 0) { + start = 0; + mRetryCount--; + throw new DownloadMission.HttpError(416); + } + + // secondary check for the file length + if (!mMission.unknownLength) + mMission.unknownLength = Utility.getContentLength(mConn) == -1; + + mF = new RandomAccessFile(mMission.getDownloadedFile(), "rw"); + mF.seek(mMission.offsets[mMission.current] + start); + + mIs = mConn.getInputStream(); + + byte[] buf = new byte[64 * 1024]; + int len = 0; + + while (mMission.running && (len = mIs.read(buf, 0, buf.length)) != -1) { + mF.write(buf, 0, len); + start += len; + mMission.notifyProgress(len); + } + + // if thread goes interrupted check if the last part mIs written. This avoid re-download the whole file + done = len == -1; } catch (Exception e) { - notifyError(DownloadMission.ERROR_UNKNOWN); + dispose(); + + // save position + mMission.setThreadBytePosition(0, start); + + if (!mMission.running || e instanceof ClosedByInterruptException) return; + + if (mRetryCount++ >= mMission.maxRetry) { + mMission.notifyError(e); + return; + } + + run();// try again + return; } - if (mMission.errCode == -1 && mMission.running) { - notifyFinished(); - } - } + dispose(); - private void notifyProgress(final long len) { - synchronized (mMission) { - mMission.notifyProgress(len); - } - } - - private void notifyError(final int err) { - synchronized (mMission) { - mMission.notifyError(err); - mMission.pause(); - } - } - - private void notifyFinished() { - synchronized (mMission) { + if (done) { mMission.notifyFinished(); + } else { + mMission.setThreadBytePosition(0, start); + } + } + + @Override + public void interrupt() { + super.interrupt(); + + if (mConn != null) { + try { + mConn.disconnect(); + } catch (Exception e) { + // nothing to do + } + } } } diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java new file mode 100644 index 000000000..b7d6908a5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -0,0 +1,16 @@ +package us.shandian.giga.get; + +public class FinishedMission extends Mission { + + public FinishedMission() { + } + + public FinishedMission(DownloadMission mission) { + source = mission.source; + length = mission.length;// ¿or mission.done? + timestamp = mission.timestamp; + name = mission.name; + location = mission.location; + kind = mission.kind; + } +} diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java new file mode 100644 index 000000000..ec2ddaa26 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -0,0 +1,66 @@ +package us.shandian.giga.get; + +import java.io.File; +import java.io.Serializable; +import java.text.SimpleDateFormat; +import java.util.Calendar; + +public abstract class Mission implements Serializable { + private static final long serialVersionUID = 0L;// last bump: 5 october 2018 + + /** + * Source url of the resource + */ + public String source; + + /** + * Length of the current resource + */ + public long length; + + /** + * creation timestamp (and maybe unique identifier) + */ + public long timestamp; + + /** + * The filename + */ + public String name; + + /** + * The directory to store the download + */ + public String location; + + /** + * pre-defined content type + */ + public char kind; + + /** + * get the target file on the storage + * + * @return File object + */ + public File getDownloadedFile() { + return new File(location, name); + } + + public boolean delete() { + deleted = true; + return getDownloadedFile().delete(); + } + + /** + * Indicate if this mission is deleted whatever is stored + */ + public transient boolean deleted = false; + + @Override + public String toString() { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(timestamp); + return "[" + calendar.getTime().toString() + "] " + location + File.separator + name; + } +} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java new file mode 100644 index 000000000..4b4d5d733 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadDataSource.java @@ -0,0 +1,73 @@ +package us.shandian.giga.get.sqlite; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; + +import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_LOCATION; +import static us.shandian.giga.get.sqlite.DownloadMissionHelper.KEY_NAME; +import static us.shandian.giga.get.sqlite.DownloadMissionHelper.MISSIONS_TABLE_NAME; + +public class DownloadDataSource { + + private static final String TAG = "DownloadDataSource"; + private final DownloadMissionHelper downloadMissionHelper; + + public DownloadDataSource(Context context) { + downloadMissionHelper = new DownloadMissionHelper(context); + } + + public ArrayList loadFinishedMissions() { + SQLiteDatabase database = downloadMissionHelper.getReadableDatabase(); + Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, + null, null, null, DownloadMissionHelper.KEY_TIMESTAMP); + + int count = cursor.getCount(); + if (count == 0) return new ArrayList<>(1); + + ArrayList result = new ArrayList<>(count); + while (cursor.moveToNext()) { + result.add(DownloadMissionHelper.getMissionFromCursor(cursor)); + } + + return result; + } + + public void addMission(DownloadMission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); + ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); + database.insert(MISSIONS_TABLE_NAME, null, values); + } + + public void deleteMission(Mission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); + database.delete(MISSIONS_TABLE_NAME, + KEY_LOCATION + " = ? AND " + + KEY_NAME + " = ?", + new String[]{downloadMission.location, downloadMission.name}); + } + + public void updateMission(DownloadMission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = downloadMissionHelper.getWritableDatabase(); + ContentValues values = DownloadMissionHelper.getValuesOfMission(downloadMission); + String whereClause = KEY_LOCATION + " = ? AND " + + KEY_NAME + " = ?"; + int rowsAffected = database.update(MISSIONS_TABLE_NAME, values, + whereClause, new String[]{downloadMission.location, downloadMission.name}); + if (rowsAffected != 1) { + Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java similarity index 63% rename from app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java rename to app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java index d5a83551b..6dadc98c8 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionSQLiteHelper.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/DownloadMissionHelper.java @@ -7,19 +7,19 @@ import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; /** - * SqliteHelper to store {@link us.shandian.giga.get.DownloadMission} + * SQLiteHelper to store finished {@link us.shandian.giga.get.DownloadMission}'s */ -public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { - - +public class DownloadMissionHelper extends SQLiteOpenHelper { private final String TAG = "DownloadMissionHelper"; // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) private static final String DATABASE_NAME = "downloads.db"; - private static final int DATABASE_VERSION = 2; + private static final int DATABASE_VERSION = 3; + /** * The table name of download missions */ @@ -30,9 +30,9 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { */ static final String KEY_LOCATION = "location"; /** - * The key to the url of a mission + * The key to the urls of a mission */ - static final String KEY_URL = "url"; + static final String KEY_SOURCE_URL = "url"; /** * The key to the name of a mission */ @@ -45,6 +45,8 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { static final String KEY_TIMESTAMP = "timestamp"; + static final String KEY_KIND = "kind"; + /** * The statement to create the table */ @@ -52,16 +54,28 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { "CREATE TABLE " + MISSIONS_TABLE_NAME + " (" + KEY_LOCATION + " TEXT NOT NULL, " + KEY_NAME + " TEXT NOT NULL, " + - KEY_URL + " TEXT NOT NULL, " + + KEY_SOURCE_URL + " TEXT NOT NULL, " + KEY_DONE + " INTEGER NOT NULL, " + KEY_TIMESTAMP + " INTEGER NOT NULL, " + + KEY_KIND + " TEXT NOT NULL, " + " UNIQUE(" + KEY_LOCATION + ", " + KEY_NAME + "));"; - - DownloadMissionSQLiteHelper(Context context) { + public DownloadMissionHelper(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); } + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(MISSIONS_CREATE_TABLE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion == 2) { + db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME + " ADD COLUMN " + KEY_KIND + " TEXT;"); + } + } + /** * Returns all values of the download mission as ContentValues. * @@ -70,34 +84,29 @@ public class DownloadMissionSQLiteHelper extends SQLiteOpenHelper { */ public static ContentValues getValuesOfMission(DownloadMission downloadMission) { ContentValues values = new ContentValues(); - values.put(KEY_URL, downloadMission.url); + values.put(KEY_SOURCE_URL, downloadMission.source); values.put(KEY_LOCATION, downloadMission.location); values.put(KEY_NAME, downloadMission.name); values.put(KEY_DONE, downloadMission.done); values.put(KEY_TIMESTAMP, downloadMission.timestamp); + values.put(KEY_KIND, String.valueOf(downloadMission.kind)); return values; } - @Override - public void onCreate(SQLiteDatabase db) { - db.execSQL(MISSIONS_CREATE_TABLE); - } - - @Override - public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - // Currently nothing to do - } - - public static DownloadMission getMissionFromCursor(Cursor cursor) { + public static FinishedMission getMissionFromCursor(Cursor cursor) { if (cursor == null) throw new NullPointerException("cursor is null"); - int pos; - String name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); - String location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); - String url = cursor.getString(cursor.getColumnIndexOrThrow(KEY_URL)); - DownloadMission mission = new DownloadMission(name, url, location); - mission.done = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + + String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); + if (kind == null || kind.isEmpty()) kind = "?"; + + FinishedMission mission = new FinishedMission(); + mission.name = cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)); + mission.location = cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)); + mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE_URL));; + mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); - mission.finished = true; + mission.kind = kind.charAt(0); + return mission; } } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java b/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java deleted file mode 100644 index e7b4caeb8..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/SQLiteDownloadDataSource.java +++ /dev/null @@ -1,79 +0,0 @@ -package us.shandian.giga.get.sqlite; - -import android.content.ContentValues; -import android.content.Context; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.util.Log; - -import java.util.ArrayList; -import java.util.List; - -import us.shandian.giga.get.DownloadDataSource; -import us.shandian.giga.get.DownloadMission; - -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_LOCATION; -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.KEY_NAME; -import static us.shandian.giga.get.sqlite.DownloadMissionSQLiteHelper.MISSIONS_TABLE_NAME; - - -/** - * Non-thread-safe implementation of {@link DownloadDataSource} - */ -public class SQLiteDownloadDataSource implements DownloadDataSource { - - private static final String TAG = "DownloadDataSourceImpl"; - private final DownloadMissionSQLiteHelper downloadMissionSQLiteHelper; - - public SQLiteDownloadDataSource(Context context) { - downloadMissionSQLiteHelper = new DownloadMissionSQLiteHelper(context); - } - - @Override - public List loadMissions() { - ArrayList result; - SQLiteDatabase database = downloadMissionSQLiteHelper.getReadableDatabase(); - Cursor cursor = database.query(MISSIONS_TABLE_NAME, null, null, - null, null, null, DownloadMissionSQLiteHelper.KEY_TIMESTAMP); - - int count = cursor.getCount(); - if (count == 0) return new ArrayList<>(); - result = new ArrayList<>(count); - while (cursor.moveToNext()) { - result.add(DownloadMissionSQLiteHelper.getMissionFromCursor(cursor)); - } - return result; - } - - @Override - public void addMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - ContentValues values = DownloadMissionSQLiteHelper.getValuesOfMission(downloadMission); - database.insert(MISSIONS_TABLE_NAME, null, values); - } - - @Override - public void updateMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - ContentValues values = DownloadMissionSQLiteHelper.getValuesOfMission(downloadMission); - String whereClause = KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?"; - int rowsAffected = database.update(MISSIONS_TABLE_NAME, values, - whereClause, new String[]{downloadMission.location, downloadMission.name}); - if (rowsAffected != 1) { - Log.e(TAG, "Expected 1 row to be affected by update but got " + rowsAffected); - } - } - - @Override - public void deleteMission(DownloadMission downloadMission) { - if (downloadMission == null) throw new NullPointerException("downloadMission is null"); - SQLiteDatabase database = downloadMissionSQLiteHelper.getWritableDatabase(); - database.delete(MISSIONS_TABLE_NAME, - KEY_LOCATION + " = ? AND " + - KEY_NAME + " = ?", - new String[]{downloadMission.location, downloadMission.name}); - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java new file mode 100644 index 000000000..b303b66cd --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4DashMuxer.java @@ -0,0 +1,31 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.Mp4DashWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; + +/** + * @author kapodamy + */ +class Mp4DashMuxer extends Postprocessing { + + Mp4DashMuxer(DownloadMission mission) { + super(mission); + recommendedReserve = 15360 * 1024;// 15 MiB + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4DashWriter muxer = new Mp4DashWriter(sources); + muxer.parseSources(); + muxer.selectTracks(0, 0); + muxer.build(out); + + 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 new file mode 100644 index 000000000..80726f705 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -0,0 +1,151 @@ +package us.shandian.giga.postprocessing; + +import android.os.Message; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.postprocessing.io.ChunkFileInputStream; +import us.shandian.giga.postprocessing.io.CircularFile; +import us.shandian.giga.service.DownloadManagerService; + +public abstract class Postprocessing { + + static final byte OK_RESULT = DownloadMission.ERROR_NOTHING; + + public static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public static final String ALGORITHM_MP4_DASH_MUXER = "mp4D"; + public static final String ALGORITHM_WEBM_MUXER = "webm"; + private static final String ALGORITHM_TEST_ALGO = "test"; + + public static Postprocessing getAlgorithm(String algorithmName, DownloadMission mission) { + if (null == algorithmName) { + throw new NullPointerException("algorithmName"); + } else switch (algorithmName) { + case ALGORITHM_TTML_CONVERTER: + return new TttmlConverter(mission); + case ALGORITHM_MP4_DASH_MUXER: + return new Mp4DashMuxer(mission); + case ALGORITHM_WEBM_MUXER: + return new WebMMuxer(mission); + case ALGORITHM_TEST_ALGO: + return new TestAlgo(mission); + /*case "example-algorithm": + return new ExampleAlgorithm(mission);*/ + default: + throw new RuntimeException("Unimplemented post-processing algorithm: " + algorithmName); + } + } + + /** + * Get a boolean value that indicate if the given algorithm work on the same + * file + */ + public boolean worksOnSameFile; + + /** + * Get the recommended space to reserve for the given algorithm. The amount + * is in bytes + */ + public int recommendedReserve; + + protected DownloadMission mission; + + Postprocessing(DownloadMission mission) { + this.mission = mission; + } + + public void run() throws IOException { + File file = mission.getDownloadedFile(); + CircularFile out = null; + ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; + + try { + int i = 0; + for (; i < sources.length - 1; i++) { + sources[i] = new ChunkFileInputStream(file, mission.offsets[i], mission.offsets[i + 1], "rw"); + } + sources[i] = new ChunkFileInputStream(file, mission.offsets[i], mission.getDownloadedFile().length(), "rw"); + + int[] idx = {0}; + CircularFile.OffsetChecker checker = () -> { + while (idx[0] < sources.length) { + /* + * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) + * or the CircularFile can lead to unexpected results + */ + if (sources[idx[0]].isDisposed() || sources[idx[0]].available() < 1) { + idx[0]++; + continue;// the selected source is not used anymore + } + + return sources[idx[0]].getFilePointer() - 1; + } + + return -1; + }; + + out = new CircularFile(file, 0, this::progressReport, checker); + + mission.done = 0; + mission.length = file.length(); + + int result = process(out, sources); + + if (result == OK_RESULT) { + long finalLength = out.finalizeFile(); + mission.done = finalLength; + mission.length = finalLength; + } else { + mission.errCode = DownloadMission.ERROR_UNKNOWN_EXCEPTION; + mission.errObject = new RuntimeException("post-processing algorithm returned " + result); + } + + if (result != OK_RESULT && worksOnSameFile) { + //noinspection ResultOfMethodCallIgnored + new File(mission.location, mission.name).delete(); + } + } finally { + for (SharpStream source : sources) { + if (source != null && !source.isDisposed()) { + source.dispose(); + } + } + if (out != null) { + out.dispose(); + } + } + } + + /** + * Abstract method to execute the pos-processing algorithm + * + * @param out output stream + * @param sources files to be processed + * @return a error code, 0 means the operation was successful + * @throws IOException if an I/O error occurs. + */ + abstract int process(SharpStream out, SharpStream... sources) throws IOException; + + String getArgumentAt(int index, String defaultValue) { + if (mission.postprocessingArgs == null || index >= mission.postprocessingArgs.length) { + return defaultValue; + } + + return mission.postprocessingArgs[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); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java b/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java new file mode 100644 index 000000000..66b235d7c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TestAlgo.java @@ -0,0 +1,54 @@ +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.util.Random; + +import us.shandian.giga.get.DownloadMission; + +/** + * Algorithm for testing proposes + */ +class TestAlgo extends Postprocessing { + + public TestAlgo(DownloadMission mission) { + super(mission); + + worksOnSameFile = true; + recommendedReserve = 4096 * 1024;// 4 KiB + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + + int written = 0; + int size = 5 * 1024 * 1024;// 5 MiB + byte[] buffer = new byte[8 * 1024];//8 KiB + mission.length = size; + + Random rnd = new Random(); + + // only write random data + sources[0].dispose(); + + while (written < size) { + rnd.nextBytes(buffer); + + int read = Math.min(buffer.length, size - written); + out.write(buffer, 0, read); + + try { + Thread.sleep((int) (Math.random() * 10)); + } catch (InterruptedException e) { + return -1; + } + + written += read; + } + + return Postprocessing.OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java new file mode 100644 index 000000000..4c9d44548 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TttmlConverter.java @@ -0,0 +1,76 @@ +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import org.schabi.newpipe.streams.io.SharpStream; +import org.schabi.newpipe.streams.SubtitleConverter; +import org.xml.sax.SAXException; + +import java.io.IOException; +import java.text.ParseException; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.xpath.XPathExpressionException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.postprocessing.io.SharpInputStream; + +/** + * @author kapodamy + */ +class TttmlConverter extends Postprocessing { + private static final String TAG = "TttmlConverter"; + + TttmlConverter(DownloadMission mission) { + super(mission); + recommendedReserve = 0;// due how XmlPullParser works, the xml is fully loaded on the ram + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + // check if the subtitle is already in srt and copy, this should never happen + String format = getArgumentAt(0, null); + + if (format == null || format.equals("ttml")) { + SubtitleConverter ttmlDumper = new SubtitleConverter(); + + try { + ttmlDumper.dumpTTML( + sources[0], + out, + getArgumentAt(1, "true").equals("true"), + getArgumentAt(2, "true").equals("true") + ); + } catch (Exception err) { + Log.e(TAG, "subtitle parse failed", err); + + if (err instanceof IOException) { + return 1; + } else if (err instanceof ParseException) { + return 2; + } else if (err instanceof SAXException) { + return 3; + } else if (err instanceof ParserConfigurationException) { + return 4; + } else if (err instanceof XPathExpressionException) { + return 7; + } + + return 8; + } + + return OK_RESULT; + } else if (format.equals("srt")) { + byte[] buffer = new byte[8 * 1024]; + int read; + while ((read = sources[0].read(buffer)) > 0) { + out.write(buffer, 0, read); + } + return OK_RESULT; + } + + throw new UnsupportedOperationException("Can't convert this subtitle, unimplemented format: " + format); + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java new file mode 100644 index 000000000..009a9a66b --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java @@ -0,0 +1,44 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipe.streams.WebMReader.TrackKind; +import org.schabi.newpipe.streams.WebMReader.WebMTrack; +import org.schabi.newpipe.streams.WebMWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; + +/** + * @author kapodamy + */ +class WebMMuxer extends Postprocessing { + + WebMMuxer(DownloadMission mission) { + super(mission); + recommendedReserve = 2048 * 1024;// 2 MiB + worksOnSameFile = true; + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + WebMWriter muxer = new WebMWriter(sources); + muxer.parseSources(); + + // youtube uses a webm with a fake video track that acts as a "cover image" + WebMTrack[] tracks = muxer.getTracksFromSource(1); + int audioTrackIndex = 0; + for (int i = 0; i < tracks.length; i++) { + if (tracks[i].kind == TrackKind.Audio) { + audioTrackIndex = i; + break; + } + } + + muxer.selectTracks(0, audioTrackIndex); + muxer.build(out); + + return OK_RESULT; + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java new file mode 100644 index 000000000..cd62c5d22 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/ChunkFileInputStream.java @@ -0,0 +1,153 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; + +public class ChunkFileInputStream extends SharpStream { + + private RandomAccessFile source; + private final long offset; + private final long length; + private long position; + + public ChunkFileInputStream(File file, long start, long end, String mode) throws IOException { + source = new RandomAccessFile(file, mode); + offset = start; + length = end - start; + position = 0; + + if (length < 1) { + source.close(); + throw new IOException("The chunk is empty or invalid"); + } + if (source.length() < end) { + try { + throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); + } finally { + source.close(); + } + } + + source.seek(offset); + } + + /** + * Get absolute position on file + * + * @return the position + */ + public long getFilePointer() { + return offset + position; + } + + @Override + public int read() throws IOException { + if ((position + 1) > length) { + return 0; + } + + int res = source.read(); + if (res >= 0) { + position++; + } + + return res; + } + + @Override + 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 { + if ((position + len) > length) { + len = (int) (length - position); + } + if (len == 0) { + return 0; + } + + int res = source.read(b, off, len); + position += res; + + return res; + } + + @Override + public long skip(long pos) throws IOException { + pos = Math.min(pos + position, length); + + if (pos == 0) { + return 0; + } + + source.seek(offset + pos); + + long oldPos = position; + position = pos; + + return pos - oldPos; + } + + @Override + public int available() { + return (int) (length - position); + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public void dispose() { + try { + source.close(); + } catch (IOException err) { + } finally { + source = null; + } + } + + @Override + public boolean isDisposed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + position = 0; + source.seek(offset); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return false; + } + + @Override + public void write(byte value) { + } + + @Override + public void write(byte[] buffer) { + } + + @Override + public void write(byte[] buffer, int offset, int count) { + } + + @Override + public void flush() { + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java b/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java new file mode 100644 index 000000000..d2fc82d33 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/CircularFile.java @@ -0,0 +1,375 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.util.ArrayList; + +public class CircularFile extends SharpStream { + + private final static int AUX_BUFFER_SIZE = 1024 * 1024;// 1 MiB + private final static int AUX_BUFFER_SIZE2 = 512 * 1024;// 512 KiB + private final static int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB + private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB + private final static boolean IMMEDIATE_AUX_BUFFER_FLUSH = false; + + private RandomAccessFile out; + private long position; + private long maxLengthKnown = -1; + + private ArrayList auxiliaryBuffers; + private OffsetChecker callback; + private ManagedBuffer queue; + private long startOffset; + private ProgressReport onProgress; + private long reportPosition; + + public CircularFile(File file, long offset, ProgressReport progressReport, OffsetChecker checker) throws IOException { + if (checker == null) { + throw new NullPointerException("checker is null"); + } + + try { + queue = new ManagedBuffer(QUEUE_BUFFER_SIZE); + out = new RandomAccessFile(file, "rw"); + out.seek(offset); + position = offset; + } catch (IOException err) { + try { + if (out != null) { + out.close(); + } + } catch (IOException e) { + // nothing to do + } + throw err; + } + + auxiliaryBuffers = new ArrayList<>(15); + callback = checker; + startOffset = offset; + reportPosition = offset; + onProgress = progressReport; + + } + + /** + * Close the file without flushing any buffer + */ + @Override + public void dispose() { + try { + auxiliaryBuffers = null; + if (out != null) { + out.close(); + out = null; + } + } catch (IOException err) { + // nothing to do + } + } + + /** + * Flush any buffer and close the output file. Use this method if the + * operation is successful + * + * @return the final length of the file + * @throws IOException if an I/O error occurs + */ + public long finalizeFile() throws IOException { + flushEverything(); + + if (maxLengthKnown > -1) { + position = maxLengthKnown; + } + if (position < out.length()) { + out.setLength(position); + } + + dispose(); + + return position; + } + + @Override + public void write(byte b) throws IOException { + write(new byte[]{b}, 0, 1); + } + + @Override + public void write(byte b[]) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte b[], int off, int len) throws IOException { + if (len == 0) { + return; + } + + long end = callback.check(); + long available; + + if (end == -1) { + available = Long.MAX_VALUE; + } else { + if (end < startOffset) { + throw new IOException("The reported offset is invalid. reported offset is " + String.valueOf(end)); + } + available = end - position; + } + + // Check if possible flush one or more auxiliary buffer + if (auxiliaryBuffers.size() > 0) { + ManagedBuffer aux = auxiliaryBuffers.get(0); + + // check if there is enough space to flush it completely + while (available >= (aux.size + queue.size)) { + available -= aux.size; + writeQueue(aux.buffer, 0, aux.size); + aux.dereference(); + auxiliaryBuffers.remove(0); + + if (auxiliaryBuffers.size() < 1) { + aux = null; + break; + } + aux = auxiliaryBuffers.get(0); + } + + if (IMMEDIATE_AUX_BUFFER_FLUSH) { + // try partial flush to avoid allocate another auxiliary buffer + if (aux != null && aux.available() < len && available > queue.size) { + int size = Math.min(aux.size, (int) available - queue.size); + + writeQueue(aux.buffer, 0, size); + aux.dereference(size); + + available -= size; + } + } + } + + if (auxiliaryBuffers.size() < 1 && available > (len + queue.size)) { + writeQueue(b, off, len); + } else { + int i = auxiliaryBuffers.size() - 1; + while (len > 0) { + if (i < 0) { + // allocate a new auxiliary buffer + auxiliaryBuffers.add(new ManagedBuffer(AUX_BUFFER_SIZE)); + i++; + } + + ManagedBuffer aux = auxiliaryBuffers.get(i); + available = aux.available(); + + if (available < 1) { + // secondary auxiliary buffer + available = len; + aux = new ManagedBuffer(Math.max(len, AUX_BUFFER_SIZE2)); + auxiliaryBuffers.add(aux); + i++; + } else { + available = Math.min(len, available); + } + + aux.write(b, off, (int) available); + + len -= available; + if (len > 0) off += available; + } + } + } + + private void writeOutside(byte buffer[], int offset, int length) throws IOException { + out.write(buffer, offset, length); + position += length; + + if (onProgress != null && position > reportPosition) { + reportPosition = position + NOTIFY_BYTES_INTERVAL; + onProgress.report(position); + } + } + + private void writeQueue(byte[] buffer, int offset, int length) throws IOException { + while (length > 0) { + if (queue.available() < length) { + flushQueue(); + + if (length >= queue.buffer.length) { + writeOutside(buffer, offset, length); + return; + } + } + + int size = Math.min(queue.available(), length); + queue.write(buffer, offset, size); + + offset += size; + length -= size; + } + + if (queue.size >= queue.buffer.length) { + flushQueue(); + } + } + + private void flushQueue() throws IOException { + writeOutside(queue.buffer, 0, queue.size); + queue.size = 0; + } + + private void flushEverything() throws IOException { + flushQueue(); + + if (auxiliaryBuffers.size() > 0) { + for (ManagedBuffer aux : auxiliaryBuffers) { + writeOutside(aux.buffer, 0, aux.size); + aux.dereference(); + } + auxiliaryBuffers.clear(); + } + } + + /** + * Flush any buffer directly to the file. Warning: use this method ONLY if + * all read dependencies are disposed + * + * @throws IOException if the dependencies are not disposed + */ + @Override + public void flush() throws IOException { + if (callback.check() != -1) { + throw new IOException("All read dependencies of this file must be disposed first"); + } + flushEverything(); + + // Save the current file length in case the method {@code rewind()} is called + if (position > maxLengthKnown) { + maxLengthKnown = position; + } + } + + @Override + public void rewind() throws IOException { + flush(); + out.seek(startOffset); + + if (onProgress != null) { + onProgress.report(-position); + } + + position = startOffset; + reportPosition = startOffset; + + } + + @Override + public long skip(long amount) throws IOException { + flush(); + position += amount; + + out.seek(position); + + return amount; + } + + @Override + public boolean isDisposed() { + return out == null; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + // + @Override + public boolean canRead() { + return false; + } + + @Override + public int read() { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int read(byte[] buffer) { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int read(byte[] buffer, int offset, int count) { + throw new UnsupportedOperationException("write-only"); + } + + @Override + public int available() { + throw new UnsupportedOperationException("write-only"); + } +// + + public interface OffsetChecker { + + /** + * Checks the amount of available space ahead + * + * @return absolute offset in the file where no more data SHOULD NOT be + * written. If the value is -1 the whole file will be used + */ + long check(); + } + + public interface ProgressReport { + + void report(long progress); + } + + class ManagedBuffer { + + byte[] buffer; + int size; + + ManagedBuffer(int length) { + buffer = new byte[length]; + } + + void dereference() { + buffer = null; + size = 0; + } + + void dereference(int amount) { + if (amount > size) { + throw new IndexOutOfBoundsException("Invalid dereference amount (" + amount + ">=" + size + ")"); + } + size -= amount; + System.arraycopy(buffer, amount, buffer, 0, size); + } + + protected int available() { + return buffer.length - size; + } + + private void write(byte[] b, int off, int len) { + System.arraycopy(b, off, buffer, size, len); + size += len; + } + + @Override + public String toString() { + return "holding: " + String.valueOf(size) + " length: " + String.valueOf(buffer.length) + " available: " + String.valueOf(available()); + } + + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java new file mode 100644 index 000000000..c1b675eef --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/FileStream.java @@ -0,0 +1,126 @@ +package us.shandian.giga.postprocessing.io; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; + +/** + * @author kapodamy + */ +public class FileStream extends SharpStream { + + public enum Mode { + Read, + ReadWrite + } + + public RandomAccessFile source; + private final Mode mode; + + public FileStream(String path, Mode mode) throws IOException { + String flags; + + if (mode == Mode.Read) { + flags = "r"; + } else { + flags = "rw"; + } + + this.mode = mode; + source = new RandomAccessFile(path, flags); + } + + @Override + public int read() throws IOException { + return source.read(); + } + + @Override + 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 { + return source.read(b, off, len); + } + + @Override + public long skip(long pos) throws IOException { + FileChannel fc = source.getChannel(); + fc.position(fc.position() + pos); + return pos; + } + + @Override + public int available() { + try { + return (int) (source.length() - source.getFilePointer()); + } catch (IOException ex) { + return 0; + } + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public void dispose() { + try { + source.close(); + } catch (IOException err) { + + } finally { + source = null; + } + } + + @Override + public boolean isDisposed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + source.getChannel().position(0); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return mode == Mode.Read || mode == Mode.ReadWrite; + } + + @Override + public boolean canWrite() { + return mode == Mode.ReadWrite; + } + + @Override + public void write(byte value) throws IOException { + source.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + source.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + source.write(buffer, offset, count); + } + + @Override + public void flush() { + } + + @Override + public void setLength(long length) throws IOException { + source.setLength(length); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java new file mode 100644 index 000000000..52e0775da --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/io/SharpInputStream.java @@ -0,0 +1,59 @@ +/* + * To change this license header, choose License Headers in Project Properties. + * To change this template file, choose Tools | Templates + * and open the template in the editor. + */ +package us.shandian.giga.postprocessing.io; + +import android.support.annotation.NonNull; + +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Wrapper for the classic {@link java.io.InputStream} + * @author kapodamy + */ +public class SharpInputStream extends InputStream { + + private final SharpStream base; + + public SharpInputStream(SharpStream base) throws IOException { + if (!base.canRead()) { + throw new IOException("The provided stream is not readable"); + } + this.base = base; + } + + @Override + public int read() throws IOException { + return base.read(); + } + + @Override + public int read(@NonNull byte[] bytes) throws IOException { + return base.read(bytes); + } + + @Override + public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { + return base.read(bytes, i, i1); + } + + @Override + public long skip(long l) throws IOException { + return base.skip(l); + } + + @Override + public int available() { + return base.available(); + } + + @Override + public void close() { + base.dispose(); + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java new file mode 100644 index 000000000..6bcf84745 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -0,0 +1,676 @@ +package us.shandian.giga.service; + +import android.content.Context; +import android.os.Handler; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v7.util.DiffUtil; +import android.util.Log; +import android.widget.Toast; + +import org.schabi.newpipe.R; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Iterator; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.get.sqlite.DownloadDataSource; +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipe.BuildConfig.DEBUG; + +public class DownloadManager { + private static final String TAG = DownloadManager.class.getSimpleName(); + + enum NetworkState {Unavailable, WifiOperating, MobileOperating, OtherOperating} + + public final static int SPECIAL_NOTHING = 0; + public final static int SPECIAL_PENDING = 1; + public final static int SPECIAL_FINISHED = 2; + + private final DownloadDataSource mDownloadDataSource; + + private final ArrayList mMissionsPending = new ArrayList<>(); + private final ArrayList mMissionsFinished; + + private final Handler mHandler; + private final File mPendingMissionsDir; + + private NetworkState mLastNetworkStatus = NetworkState.Unavailable; + + int mPrefMaxRetry; + boolean mPrefCrossNetwork; + + /** + * Create a new instance + * + * @param context Context for the data source for finished downloads + * @param handler Thread required for Messaging + */ + DownloadManager(@NonNull Context context, Handler handler) { + if (DEBUG) { + Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); + } + + mDownloadDataSource = new DownloadDataSource(context); + mHandler = handler; + mMissionsFinished = loadFinishedMissions(); + mPendingMissionsDir = getPendingDir(context); + + if (!Utility.mkdir(mPendingMissionsDir, false)) { + throw new RuntimeException("failed to create pending_downloads in data directory"); + } + + loadPendingMissions(); + } + + private static File getPendingDir(@NonNull Context context) { + //File dir = new File(ContextCompat.getDataDir(context), "pending_downloads"); + File dir = context.getExternalFilesDir("pending_downloads"); + + 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"); + } + + return dir; + } + + /** + * Loads finished missions from the data source + */ + private ArrayList loadFinishedMissions() { + ArrayList finishedMissions = mDownloadDataSource.loadFinishedMissions(); + + // missions always is stored by creation order, simply reverse the list + ArrayList result = new ArrayList<>(finishedMissions.size()); + for (int i = finishedMissions.size() - 1; i >= 0; i--) { + FinishedMission mission = finishedMissions.get(i); + File file = mission.getDownloadedFile(); + + if (!file.isFile()) { + if (DEBUG) { + Log.d(TAG, "downloaded file removed: " + file.getAbsolutePath()); + } + mDownloadDataSource.deleteMission(mission); + continue; + } + + result.add(mission); + } + + return result; + } + + private void loadPendingMissions() { + File[] subs = mPendingMissionsDir.listFiles(); + + if (subs == null) { + Log.e(TAG, "listFiles() returned null"); + return; + } + if (subs.length < 1) { + return; + } + if (DEBUG) { + Log.d(TAG, "Loading pending downloads from directory: " + mPendingMissionsDir.getAbsolutePath()); + } + + for (File sub : subs) { + if (sub.isFile()) { + DownloadMission mis = Utility.readFromFile(sub); + + if (mis == null) { + //noinspection ResultOfMethodCallIgnored + sub.delete(); + } else { + if (mis.isFinished()) { + //noinspection ResultOfMethodCallIgnored + sub.delete(); + continue; + } + + File dl = mis.getDownloadedFile(); + boolean exists = dl.exists(); + + if (mis.postprocessingRunning && mis.postprocessingThis) { + // Incomplete post-processing results in a corrupted download file + // because the selected algorithm works on the same file to save space. + if (!dl.delete()) { + Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); + } + exists = true; + mis.postprocessingRunning = false; + mis.errCode = DownloadMission.ERROR_POSTPROCESSING_FAILED; + mis.errObject = new RuntimeException("stopped unexpectedly"); + } else if (exists && !dl.isFile()) { + // probably a folder, this should never happens + if (!sub.delete()) { + Log.w(TAG, "Unable to delete serialized file: " + sub.getPath()); + } + continue; + } + + if (!exists) { + // downloaded file deleted, reset mission state + DownloadMission m = new DownloadMission(mis.urls, mis.name, mis.location, mis.kind, mis.postprocessingName, mis.postprocessingArgs); + m.timestamp = mis.timestamp; + m.threadCount = mis.threadCount; + m.source = mis.source; + m.maxRetry = mis.maxRetry; + m.nearLength = mis.nearLength; + mis = m; + } + + mis.running = false; + mis.recovered = exists; + mis.metadata = sub; + mis.mHandler = mHandler; + + mMissionsPending.add(mis); + } + } + } + + if (mMissionsPending.size() > 1) { + Collections.sort(mMissionsPending, (mission1, mission2) -> Long.compare(mission1.timestamp, mission2.timestamp)); + } + } + + /** + * Start a new download mission + * + * @param urls the list of urls to download + * @param location the location + * @param name the name of the file to create + * @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. + */ + void startMission(String[] urls, String location, String name, char kind, int threads, + String source, String psName, String[] psArgs, long nearLength) { + synchronized (this) { + // check for existing pending download + DownloadMission pendingMission = getPendingMission(location, name); + if (pendingMission != null) { + // generate unique filename (?) + try { + name = generateUniqueName(location, name); + } catch (Exception e) { + Log.e(TAG, "Unable to generate unique name", e); + name = System.currentTimeMillis() + name; + Log.i(TAG, "Using " + name); + } + } else { + // check for existing finished download + int index = getFinishedMissionIndex(location, name); + if (index >= 0) mDownloadDataSource.deleteMission(mMissionsFinished.remove(index)); + } + + DownloadMission mission = new DownloadMission(urls, name, location, kind, psName, psArgs); + mission.timestamp = System.currentTimeMillis(); + mission.threadCount = threads; + mission.source = source; + mission.mHandler = mHandler; + mission.maxRetry = mPrefMaxRetry; + mission.nearLength = nearLength; + + while (true) { + mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); + if (!mission.metadata.isFile() && !mission.metadata.exists()) { + try { + if (!mission.metadata.createNewFile()) + throw new RuntimeException("Cant create download metadata file"); + } catch (IOException e) { + throw new RuntimeException(e); + } + break; + } + mission.timestamp = System.currentTimeMillis(); + } + + mMissionsPending.add(mission); + + // Before starting, save the state in case the internet connection is not available + Utility.writeToFile(mission.metadata, mission); + + if (canDownloadInCurrentNetwork() && (getRunningMissionsCount() < 1)) { + mission.start(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); + } + } + } + + + public void resumeMission(DownloadMission mission) { + if (!mission.running) { + mission.start(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_RUNNING); + } + } + + public void pauseMission(DownloadMission mission) { + if (mission.running) { + mission.pause(); + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + } + } + + public void deleteMission(Mission mission) { + synchronized (this) { + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mDownloadDataSource.deleteMission(mission); + } + + mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); + mission.delete(); + } + } + + + /** + * Get a pending mission by its location and name + * + * @param location the location + * @param name the name + * @return the mission or null if no such mission exists + */ + @Nullable + private DownloadMission getPendingMission(String location, String name) { + for (DownloadMission mission : mMissionsPending) { + if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + return mission; + } + } + return null; + } + + /** + * Get a finished mission by its location and name + * + * @param location the location + * @param name the name + * @return the mission index or -1 if no such mission exists + */ + private int getFinishedMissionIndex(String location, String name) { + for (int i = 0; i < mMissionsFinished.size(); i++) { + FinishedMission mission = mMissionsFinished.get(i); + if (location.equalsIgnoreCase(mission.location) && name.equalsIgnoreCase(mission.name)) { + return i; + } + } + + return -1; + } + + public Mission getAnyMission(String location, String name) { + synchronized (this) { + Mission mission = getPendingMission(location, name); + if (mission != null) return mission; + + int idx = getFinishedMissionIndex(location, name); + if (idx >= 0) return mMissionsFinished.get(idx); + } + + return null; + } + + int getRunningMissionsCount() { + int count = 0; + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running && mission.errCode != DownloadMission.ERROR_POSTPROCESSING_FAILED && !mission.isFinished()) + count++; + } + } + + return count; + } + + void pauseAllMissions() { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) mission.pause(); + } + } + + + /** + * Splits the filename into name and extension + *

+ * Dots are ignored if they appear: not at all, at the beginning of the file, + * at the end of the file + * + * @param name the name to split + * @return a string array with a length of 2 containing the name and the extension + */ + private static String[] splitName(String name) { + int dotIndex = name.lastIndexOf('.'); + if (dotIndex <= 0 || (dotIndex == name.length() - 1)) { + return new String[]{name, ""}; + } else { + return new String[]{name.substring(0, dotIndex), name.substring(dotIndex + 1)}; + } + } + + /** + * Generates a unique file name. + *

+ * e.g. "myName (1).txt" if the name "myName.txt" exists. + * + * @param location the location (to check for existing files) + * @param name the name of the file + * @return the unique file name + * @throws IllegalArgumentException if the location is not a directory + * @throws SecurityException if the location is not readable + */ + private static String generateUniqueName(String location, String name) { + if (location == null) throw new NullPointerException("location is null"); + if (name == null) throw new NullPointerException("name is null"); + File destination = new File(location); + if (!destination.isDirectory()) { + throw new IllegalArgumentException("location is not a directory: " + location); + } + final String[] nameParts = splitName(name); + String[] existingName = destination.list((dir, name1) -> name1.startsWith(nameParts[0])); + Arrays.sort(existingName); + String newName; + int downloadIndex = 0; + do { + newName = nameParts[0] + " (" + downloadIndex + ")." + nameParts[1]; + ++downloadIndex; + if (downloadIndex == 1000) { // Probably an error on our side + throw new RuntimeException("Too many existing files"); + } + } while (Arrays.binarySearch(existingName, newName) >= 0); + return newName; + } + + /** + * Set a pending download as finished + * + * @param mission the desired mission + */ + void setFinished(DownloadMission mission) { + synchronized (this) { + mMissionsPending.remove(mission); + mMissionsFinished.add(0, new FinishedMission(mission)); + mDownloadDataSource.addMission(mission); + } + } + + /** + * runs another mission in queue if possible + * + * @return true if exits pending missions running or a mission was started, otherwise, false + */ + boolean runAnotherMission() { + synchronized (this) { + if (mMissionsPending.size() < 1) return false; + + int i = getRunningMissionsCount(); + if (i > 0) return true; + + if (!canDownloadInCurrentNetwork()) return false; + + for (DownloadMission mission : mMissionsPending) { + if (!mission.running && mission.errCode == DownloadMission.ERROR_NOTHING && mission.enqueued) { + resumeMission(mission); + return true; + } + } + + return false; + } + } + + public MissionIterator getIterator() { + return new MissionIterator(); + } + + /** + * Forget all finished downloads, but, doesn't delete any file + */ + public void forgetFinishedDownloads() { + synchronized (this) { + for (FinishedMission mission : mMissionsFinished) { + mDownloadDataSource.deleteMission(mission); + } + mMissionsFinished.clear(); + } + } + + private boolean canDownloadInCurrentNetwork() { + if (mLastNetworkStatus == NetworkState.Unavailable) return false; + return !(mPrefCrossNetwork && mLastNetworkStatus == NetworkState.MobileOperating); + } + + void handleConnectivityChange(NetworkState currentStatus) { + if (currentStatus == mLastNetworkStatus) return; + + mLastNetworkStatus = currentStatus; + + if (currentStatus == NetworkState.Unavailable) { + return; + } else if (currentStatus != NetworkState.MobileOperating || !mPrefCrossNetwork) { + return; + } + + boolean flag = false; + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running && mission.isFinished() && !mission.postprocessingRunning) { + flag = true; + mission.pause(); + } + } + } + + if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); + } + + void updateMaximumAttempts() { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) mission.maxRetry = mPrefMaxRetry; + } + } + + /** + * 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(); + } + + void checkForRunningMission(String location, String name, DownloadManagerService.DMChecker check) { + boolean listed; + boolean finished = false; + + synchronized (this) { + DownloadMission mission = getPendingMission(location, name); + if (mission != null) { + listed = true; + } else { + listed = getFinishedMissionIndex(location, name) >= 0; + finished = listed; + } + } + + check.callback(listed, finished); + } + + public class MissionIterator extends DiffUtil.Callback { + final Object FINISHED = new Object(); + final Object PENDING = new Object(); + + ArrayList snapshot; + ArrayList current; + ArrayList hidden; + + private MissionIterator() { + hidden = new ArrayList<>(2); + current = null; + snapshot = getSpecialItems(); + } + + private ArrayList getSpecialItems() { + synchronized (DownloadManager.this) { + ArrayList pending = new ArrayList<>(mMissionsPending); + ArrayList finished = new ArrayList<>(mMissionsFinished); + ArrayList remove = new ArrayList<>(hidden); + + // hide missions (if required) + Iterator iterator = remove.iterator(); + while (iterator.hasNext()) { + Mission mission = iterator.next(); + if (pending.remove(mission) || finished.remove(mission)) iterator.remove(); + } + + int fakeTotal = pending.size(); + if (fakeTotal > 0) fakeTotal++; + + fakeTotal += finished.size(); + if (finished.size() > 0) fakeTotal++; + + ArrayList list = new ArrayList<>(fakeTotal); + if (pending.size() > 0) { + list.add(PENDING); + list.addAll(pending); + } + if (finished.size() > 0) { + list.add(FINISHED); + list.addAll(finished); + } + + + return list; + } + } + + public MissionItem getItem(int position) { + Object object = snapshot.get(position); + + if (object == PENDING) return new MissionItem(SPECIAL_PENDING); + if (object == FINISHED) return new MissionItem(SPECIAL_FINISHED); + + return new MissionItem(SPECIAL_NOTHING, (Mission) object); + } + + public int getSpecialAtItem(int position) { + Object object = snapshot.get(position); + + if (object == PENDING) return SPECIAL_PENDING; + if (object == FINISHED) return SPECIAL_FINISHED; + + return SPECIAL_NOTHING; + } + + public MissionItem getItemUnsafe(int position) { + synchronized (DownloadManager.this) { + int count = mMissionsPending.size(); + int count2 = mMissionsFinished.size(); + + if (count > 0) { + position--; + if (position == -1) + return new MissionItem(SPECIAL_PENDING); + else if (position < count) + return new MissionItem(SPECIAL_NOTHING, mMissionsPending.get(position)); + else if (position == count && count2 > 0) + return new MissionItem(SPECIAL_FINISHED); + else + position -= count; + } else { + if (count2 > 0 && position == 0) { + return new MissionItem(SPECIAL_FINISHED); + } + } + + position--; + + if (count2 < 1) { + throw new RuntimeException( + String.format("Out of range. pending_count=%s finished_count=%s position=%s", count, count2, position) + ); + } + + return new MissionItem(SPECIAL_NOTHING, mMissionsFinished.get(position)); + } + } + + + public void start() { + current = getSpecialItems(); + } + + public void end() { + snapshot = current; + current = null; + } + + public void hide(Mission mission) { + hidden.add(mission); + } + + public void unHide(Mission mission) { + hidden.remove(mission); + } + + + @Override + public int getOldListSize() { + return snapshot.size(); + } + + @Override + public int getNewListSize() { + return current.size(); + } + + @Override + public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { + return snapshot.get(oldItemPosition) == current.get(newItemPosition); + } + + @Override + public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { + return areItemsTheSame(oldItemPosition, newItemPosition); + } + } + + public class MissionItem { + public int special; + public Mission mission; + + MissionItem(int s, Mission m) { + special = s; + mission = m; + } + + MissionItem(int s) { + this(s, null); + } + } + +} 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 ff410a79a..a57fe1734 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -2,67 +2,114 @@ package us.shandian.giga.service; import android.Manifest; import android.app.Notification; +import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; +import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; import android.net.Uri; import android.os.Binder; +import android.os.Build; import android.os.Handler; -import android.os.HandlerThread; import android.os.IBinder; +import android.os.Looper; import android.os.Message; +import android.preference.PreferenceManager; +import android.support.v4.app.NotificationCompat; import android.support.v4.app.NotificationCompat.Builder; import android.support.v4.content.PermissionChecker; import android.util.Log; +import android.util.SparseArray; import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; -import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.player.helper.LockManager; +import java.io.File; import java.util.ArrayList; -import us.shandian.giga.get.DownloadDataSource; -import us.shandian.giga.get.DownloadManager; -import us.shandian.giga.get.DownloadManagerImpl; import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.sqlite.SQLiteDownloadDataSource; +import us.shandian.giga.service.DownloadManager.NetworkState; +import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadManagerService extends Service { - private static final String TAG = DownloadManagerService.class.getSimpleName(); + private static final String TAG = "DownloadManagerService"; - /** - * Message code of update messages stored as {@link Message#what}. - */ - private static final int UPDATE_MESSAGE = 0; - private static final int NOTIFICATION_ID = 1000; + 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; + + private static final int FOREGROUND_NOTIFICATION_ID = 1000; + private static final int DOWNLOADS_NOTIFICATION_ID = 1001; + + private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; private static final String EXTRA_NAME = "DownloadManagerService.extra.name"; private static final String EXTRA_LOCATION = "DownloadManagerService.extra.location"; - private static final String EXTRA_IS_AUDIO = "DownloadManagerService.extra.is_audio"; + private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; + private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; + private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; + private static final String EXTRA_SOURCE = "DownloadManagerService.extra.source"; + private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; + 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"; private DMBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; - private long mLastTimeStamp = System.currentTimeMillis(); - private DownloadDataSource mDataSource; + private boolean mForeground = false; + private NotificationManager notificationManager = null; + private boolean mDownloadNotificationEnable = true; + private int downloadDoneCount = 0; + private Builder downloadDoneNotification = null; + private StringBuilder downloadDoneList = null; - private final MissionListener missionListener = new MissionListener(); + private final ArrayList mEchoObservers = new ArrayList<>(1); + private BroadcastReceiver mNetworkStateListener; - private void notifyMediaScanner(DownloadMission mission) { - Uri uri = Uri.parse("file://" + mission.location + "/" + mission.name); - // notify media scanner on downloaded media file ... - sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri)); + private SharedPreferences mPrefs = null; + private final SharedPreferences.OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; + + private boolean mLockAcquired = false; + private LockManager mLock = null; + + private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; + private Builder downloadFailedNotification = null; + private SparseArray mFailedDownloads = new SparseArray<>(5); + + private Bitmap icLauncher; + private Bitmap icDownloadDone; + private Bitmap icDownloadFailed; + + private PendingIntent mOpenDownloadList; + + /** + * notify media scanner on downloaded media file ... + * + * @param file the downloaded file + */ + private void notifyMediaScanner(File file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file))); } @Override @@ -74,87 +121,92 @@ public class DownloadManagerService extends Service { } mBinder = new DMBinder(); - if (mDataSource == null) { - mDataSource = new SQLiteDownloadDataSource(this); - } - if (mManager == null) { - ArrayList paths = new ArrayList<>(2); - paths.add(NewPipeSettings.getVideoDownloadPath(this)); - paths.add(NewPipeSettings.getAudioDownloadPath(this)); - mManager = new DownloadManagerImpl(paths, mDataSource, this); - if (DEBUG) { - Log.d(TAG, "mManager == null"); - Log.d(TAG, "Download directory: " + paths); + mHandler = new Handler(Looper.myLooper()) { + @Override + public void handleMessage(Message msg) { + DownloadManagerService.this.handleMessage(msg); } - } + }; + + mManager = new DownloadManager(this, mHandler); Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN); - PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, + mOpenDownloadList = PendingIntent.getActivity(this, 0, openDownloadListIntent, PendingIntent.FLAG_UPDATE_CURRENT); - Bitmap iconBitmap = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); + icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); Builder builder = new Builder(this, getString(R.string.notification_channel_id)) - .setContentIntent(pendingIntent) + .setContentIntent(mOpenDownloadList) .setSmallIcon(android.R.drawable.stat_sys_download) - .setLargeIcon(iconBitmap) + .setLargeIcon(icLauncher) .setContentTitle(getString(R.string.msg_running)) .setContentText(getString(R.string.msg_running_detail)); mNotification = builder.build(); + notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - HandlerThread thread = new HandlerThread("ServiceMessenger"); - thread.start(); - - mHandler = new Handler(thread.getLooper()) { + mNetworkStateListener = new BroadcastReceiver() { @Override - public void handleMessage(Message msg) { - switch (msg.what) { - case UPDATE_MESSAGE: { - int runningCount = 0; - - for (int i = 0; i < mManager.getCount(); i++) { - if (mManager.getMission(i).running) { - runningCount++; - } - } - updateState(runningCount); - break; - } + public void onReceive(Context context, Intent intent) { + if (intent.getBooleanExtra(ConnectivityManager.EXTRA_NO_CONNECTIVITY, false)) { + handleConnectivityChange(null); + return; } + handleConnectivityChange(intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO)); } }; + registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - } + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); - private void startMissionAsync(final String url, final String location, final String name, - final boolean isAudio, final int threads) { - mHandler.post(new Runnable() { - @Override - public void run() { - int missionId = mManager.startMission(url, location, name, isAudio, threads); - mBinder.onMissionAdded(mManager.getMission(missionId)); - } - }); + handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); + + mLock = new LockManager(this); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (DEBUG) { + if (intent == null) { + Log.d(TAG, "Restarting"); + return START_NOT_STICKY; + } Log.d(TAG, "Starting"); } Log.i(TAG, "Got intent: " + intent); String action = intent.getAction(); - if (action != null && action.equals(Intent.ACTION_RUN)) { - String name = intent.getStringExtra(EXTRA_NAME); - String location = intent.getStringExtra(EXTRA_LOCATION); - int threads = intent.getIntExtra(EXTRA_THREADS, 1); - boolean isAudio = intent.getBooleanExtra(EXTRA_IS_AUDIO, false); - String url = intent.getDataString(); - startMissionAsync(url, location, name, isAudio, threads); + if (action != null) { + if (action.equals(Intent.ACTION_RUN)) { + String[] urls = intent.getStringArrayExtra(EXTRA_URLS); + String name = intent.getStringExtra(EXTRA_NAME); + String location = intent.getStringExtra(EXTRA_LOCATION); + int threads = intent.getIntExtra(EXTRA_THREADS, 1); + char kind = intent.getCharExtra(EXTRA_KIND, '?'); + String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); + String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); + String source = intent.getStringExtra(EXTRA_SOURCE); + long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); + + mHandler.post(() -> mManager.startMission(urls, location, name, kind, threads, source, psName, psArgs, nearLength)); + + } else if (downloadDoneNotification != null) { + if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { + downloadDoneCount = 0; + downloadDoneList.setLength(0); + } + if (action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { + startActivity(new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ); + } + } } return START_NOT_STICKY; } @@ -167,11 +219,23 @@ public class DownloadManagerService extends Service { Log.d(TAG, "Destroying"); } - for (int i = 0; i < mManager.getCount(); i++) { - mManager.pauseMission(i); + stopForeground(true); + + if (notificationManager != null && downloadDoneNotification != null) { + downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc + notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } - stopForeground(true); + mManager.pauseAllMissions(); + + manageLock(false); + + unregisterReceiver(mNetworkStateListener); + mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); + + if (icDownloadDone != null) icDownloadDone.recycle(); + if (icDownloadFailed != null) icDownloadFailed.recycle(); + if (icLauncher != null) icLauncher.recycle(); } @Override @@ -192,53 +256,236 @@ public class DownloadManagerService extends Service { return mBinder; } - private void postUpdateMessage() { - mHandler.sendEmptyMessage(UPDATE_MESSAGE); - } + public void handleMessage(Message msg) { + DownloadMission mission = (DownloadMission) msg.obj; - private void updateState(int runningCount) { - if (runningCount == 0) { - stopForeground(true); - } else { - startForeground(NOTIFICATION_ID, mNotification); + switch (msg.what) { + case MESSAGE_FINISHED: + notifyMediaScanner(mission.getDownloadedFile()); + notifyFinishedDownload(mission.name); + mManager.setFinished(mission); + updateForegroundState(mManager.runAnotherMission()); + break; + case MESSAGE_RUNNING: + case MESSAGE_PROGRESS: + updateForegroundState(true); + break; + case MESSAGE_ERROR: + notifyFailedDownload(mission); + updateForegroundState(mManager.runAnotherMission()); + break; + case MESSAGE_PAUSED: + updateForegroundState(mManager.getRunningMissionsCount() > 0); + break; + } + + if (msg.what != MESSAGE_ERROR) + mFailedDownloads.delete(mFailedDownloads.indexOfValue(mission)); + + synchronized (mEchoObservers) { + for (Handler handler : mEchoObservers) { + Message echo = new Message(); + echo.what = msg.what; + echo.obj = msg.obj; + + handler.sendMessage(echo); + } } } - public static void startMission(Context context, String url, String location, String name, boolean isAudio, int threads) { + private void handleConnectivityChange(NetworkInfo info) { + NetworkState status; + + if (info == null) { + status = NetworkState.Unavailable; + Log.i(TAG, "actual connectivity status is unavailable"); + } else if (!info.isAvailable() || !info.isConnected()) { + status = NetworkState.Unavailable; + Log.i(TAG, "actual connectivity status is not available and not connected"); + } else { + int type = info.getType(); + if (type == ConnectivityManager.TYPE_MOBILE || type == ConnectivityManager.TYPE_MOBILE_DUN) { + status = NetworkState.MobileOperating; + } else if (type == ConnectivityManager.TYPE_WIFI) { + status = NetworkState.WifiOperating; + } else if (type == ConnectivityManager.TYPE_WIMAX || + type == ConnectivityManager.TYPE_ETHERNET || + type == ConnectivityManager.TYPE_BLUETOOTH) { + status = NetworkState.OtherOperating; + } else { + status = NetworkState.Unavailable; + } + Log.i(TAG, "actual connectivity status is " + status.name()); + } + + if (mManager == null) return;// avoid race-conditions while the service is starting + mManager.handleConnectivityChange(status); + } + + private void handlePreferenceChange(SharedPreferences prefs, String key) { + if (key.equals(getString(R.string.downloads_maximum_retry))) { + try { + String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); + mManager.mPrefMaxRetry = Integer.parseInt(value); + } catch (Exception e) { + mManager.mPrefMaxRetry = 0; + } + mManager.updateMaximumAttempts(); + } else if (key.equals(getString(R.string.downloads_cross_network))) { + mManager.mPrefCrossNetwork = prefs.getBoolean(key, false); + } + } + + public void updateForegroundState(boolean state) { + if (state == mForeground) return; + + if (state) { + startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); + } else { + stopForeground(true); + } + + manageLock(state); + + mForeground = state; + } + + public static void startMission(Context context, String urls[], String location, String name, char kind, + int threads, String source, String psName, String[] psArgs, long nearLength) { Intent intent = new Intent(context, DownloadManagerService.class); intent.setAction(Intent.ACTION_RUN); - intent.setData(Uri.parse(url)); + intent.putExtra(EXTRA_URLS, urls); intent.putExtra(EXTRA_NAME, name); intent.putExtra(EXTRA_LOCATION, location); - intent.putExtra(EXTRA_IS_AUDIO, isAudio); + intent.putExtra(EXTRA_KIND, kind); intent.putExtra(EXTRA_THREADS, threads); + intent.putExtra(EXTRA_SOURCE, source); + intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); + intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); + intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); context.startService(intent); } + public static void checkForRunningMission(Context context, String location, String name, DMChecker check) { + Intent intent = new Intent(); + intent.setClass(context, DownloadManagerService.class); + context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName cname, IBinder service) { + try { + ((DMBinder) service).getDownloadManager().checkForRunningMission(location, name, check); + } catch (Exception err) { + Log.w(TAG, "checkForRunningMission() callback is defective", err); + } - private class MissionListener implements DownloadMission.MissionListener { - @Override - public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { - long now = System.currentTimeMillis(); - long delta = now - mLastTimeStamp; - if (delta > 2000) { - postUpdateMessage(); - mLastTimeStamp = now; + // TODO: find a efficient way to unbind the service. This destroy the service due idle, but is started again when the user start a download. + context.unbindService(this); } + + @Override + public void onServiceDisconnected(ComponentName name) { + } + }, Context.BIND_AUTO_CREATE); + } + + public void notifyFinishedDownload(String name) { + if (!mDownloadNotificationEnable || notificationManager == null) { + return; } - @Override - public void onFinish(DownloadMission downloadMission) { - postUpdateMessage(); - notifyMediaScanner(downloadMission); + if (downloadDoneNotification == null) { + downloadDoneList = new StringBuilder(name.length()); + + icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); + downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadDone) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setDeleteIntent(makePendingIntent(ACTION_RESET_DOWNLOAD_FINISHED)) + .setContentIntent(makePendingIntent(ACTION_OPEN_DOWNLOADS_FINISHED)); } - @Override - public void onError(DownloadMission downloadMission, int errCode) { - postUpdateMessage(); + if (downloadDoneCount < 1) { + downloadDoneList.append(name); + + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + downloadDoneNotification.setContentTitle(getString(R.string.app_name)); + } else { + downloadDoneNotification.setContentTitle(null); + } + + downloadDoneNotification.setContentText(getString(R.string.download_finished)); + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() + .setBigContentTitle(getString(R.string.download_finished)) + .bigText(name) + ); + } else { + downloadDoneList.append('\n'); + downloadDoneList.append(name); + + downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle().bigText(downloadDoneList)); + downloadDoneNotification.setContentTitle(getString(R.string.download_finished_more, String.valueOf(downloadDoneCount + 1))); + downloadDoneNotification.setContentText(downloadDoneList); + } + + notificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + downloadDoneCount++; + } + + public void notifyFailedDownload(DownloadMission mission) { + if (!mDownloadNotificationEnable || mFailedDownloads.indexOfValue(mission) >= 0) return; + + int id = downloadFailedNotificationID++; + mFailedDownloads.put(id, mission); + + if (downloadFailedNotification == null) { + icDownloadFailed = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_warning); + downloadFailedNotification = new Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadFailed) + .setSmallIcon(android.R.drawable.stat_sys_warning) + .setContentIntent(mOpenDownloadList); + } + + if (android.os.Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + downloadFailedNotification.setContentTitle(getString(R.string.app_name)); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(getString(R.string.download_failed).concat(": ").concat(mission.name))); + } else { + downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); + downloadFailedNotification.setContentText(mission.name); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mission.name)); + } + + notificationManager.notify(id, downloadFailedNotification.build()); + } + + private PendingIntent makePendingIntent(String action) { + Intent intent = new Intent(this, DownloadManagerService.class).setAction(action); + return PendingIntent.getService(this, intent.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + private void manageObservers(Handler handler, boolean add) { + synchronized (mEchoObservers) { + if (add) { + mEchoObservers.add(handler); + } else { + mEchoObservers.remove(handler); + } } } + private void manageLock(boolean acquire) { + if (acquire == mLockAcquired) return; + + if (acquire) + mLock.acquireWifiAndCpu(); + else + mLock.releaseWifiAndCpu(); + + mLockAcquired = acquire; + } // Wrapper of DownloadManager public class DMBinder extends Binder { @@ -246,14 +493,38 @@ public class DownloadManagerService extends Service { return mManager; } - public void onMissionAdded(DownloadMission mission) { - mission.addListener(missionListener); - postUpdateMessage(); + public void addMissionEventListener(Handler handler) { + manageObservers(handler, true); } - public void onMissionRemoved(DownloadMission mission) { - mission.removeListener(missionListener); - postUpdateMessage(); + public void removeMissionEventListener(Handler handler) { + manageObservers(handler, false); } + + public void clearDownloadNotifications() { + if (notificationManager == null) return; + if (downloadDoneNotification != null) { + notificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); + downloadDoneList.setLength(0); + downloadDoneCount = 0; + } + if (downloadFailedNotification != null) { + for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { + notificationManager.cancel(downloadFailedNotificationID); + } + mFailedDownloads.clear(); + downloadFailedNotificationID++; + } + } + + public void enableNotifications(boolean enable) { + mDownloadNotificationEnable = enable; + } + } + + public interface DMChecker { + void callback(boolean listed, boolean finished); + } + } 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 d5555c2be..df5f9e429 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 @@ -1,5 +1,6 @@ package us.shandian.giga.ui.adapter; +import android.annotation.SuppressLint; import android.app.Activity; import android.app.ProgressDialog; import android.content.Context; @@ -7,12 +8,21 @@ import android.content.Intent; import android.net.Uri; import android.os.AsyncTask; import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v4.content.FileProvider; import android.support.v4.view.ViewCompat; +import android.support.v7.app.AlertDialog; +import android.support.v7.util.DiffUtil; import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.support.v7.widget.RecyclerView.Adapter; import android.util.Log; +import android.util.SparseArray; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -24,270 +34,290 @@ import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; -import org.schabi.newpipe.download.DeleteDownloadManager; +import org.schabi.newpipe.util.NavigationHelper; import java.io.File; import java.lang.ref.WeakReference; import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import us.shandian.giga.get.DownloadManager; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.ui.common.Deleter; import us.shandian.giga.ui.common.ProgressDrawable; import us.shandian.giga.util.Utility; import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; 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_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; +import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_FAILED; +import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; -public class MissionAdapter extends RecyclerView.Adapter { - private static final Map ALGORITHMS = new HashMap<>(); +public class MissionAdapter extends Adapter { + private static final SparseArray ALGORITHMS = new SparseArray<>(); private static final String TAG = "MissionAdapter"; + private static final String UNDEFINED_SPEED = "--.-%"; static { ALGORITHMS.put(R.id.md5, "MD5"); ALGORITHMS.put(R.id.sha1, "SHA1"); } - private Activity mContext; + private Context mContext; private LayoutInflater mInflater; private DownloadManager mDownloadManager; - private DeleteDownloadManager mDeleteDownloadManager; - private List mItemList; - private DownloadManagerService.DMBinder mBinder; + private Deleter mDeleter; private int mLayout; + private DownloadManager.MissionIterator mIterator; + private ArrayList mPendingDownloadsItems = new ArrayList<>(); + private Handler mHandler; + private MenuItem mClear; + private View mEmptyMessage; - public MissionAdapter(Activity context, DownloadManagerService.DMBinder binder, DownloadManager downloadManager, DeleteDownloadManager deleteDownloadManager, boolean isLinear) { + public MissionAdapter(Context context, DownloadManager downloadManager, MenuItem clearButton, View emptyMessage) { mContext = context; mDownloadManager = downloadManager; - mDeleteDownloadManager = deleteDownloadManager; - mBinder = binder; + mDeleter = null; mInflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); - mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; + mLayout = R.layout.mission_item; - mItemList = new ArrayList<>(); - updateItemList(); + mHandler = new Handler(Looper.myLooper()) { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case DownloadManagerService.MESSAGE_PROGRESS: + case DownloadManagerService.MESSAGE_ERROR: + case DownloadManagerService.MESSAGE_FINISHED: + onServiceMessage(msg); + break; + } + } + }; + + mClear = clearButton; + mEmptyMessage = emptyMessage; + + mIterator = downloadManager.getIterator(); + + checkEmptyMessageVisibility(); } - public void updateItemList() { - mItemList.clear(); - - for (int i = 0; i < mDownloadManager.getCount(); i++) { - DownloadMission mission = mDownloadManager.getMission(i); - if (!mDeleteDownloadManager.contains(mission)) { - mItemList.add(mDownloadManager.getMission(i)); - } + @Override + @NonNull + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + switch (viewType) { + case DownloadManager.SPECIAL_PENDING: + case DownloadManager.SPECIAL_FINISHED: + return new ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)); } + + return new ViewHolderItem(mInflater.inflate(mLayout, parent, false)); } @Override - public MissionAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - final ViewHolder h = new ViewHolder(mInflater.inflate(mLayout, parent, false)); + public void onViewRecycled(@NonNull ViewHolder view) { + super.onViewRecycled(view); - h.menu.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - buildPopup(h); - } - }); + if (view instanceof ViewHolderHeader) return; + ViewHolderItem h = (ViewHolderItem) view; - /*h.itemView.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - showDetail(h); - } - });*/ + if (h.item.mission instanceof DownloadMission) { + mPendingDownloadsItems.remove(h); + if (mPendingDownloadsItems.size() < 1) setAutoRefresh(false); + } - return h; - } - - @Override - public void onViewRecycled(MissionAdapter.ViewHolder h) { - super.onViewRecycled(h); - h.mission.removeListener(h.observer); - h.mission = null; - h.observer = null; - h.progress = null; - h.position = -1; + h.popupMenu.dismiss(); + h.item = null; h.lastTimeStamp = -1; h.lastDone = -1; - h.colorId = 0; + h.lastCurrent = -1; + h.state = 0; } @Override - public void onBindViewHolder(MissionAdapter.ViewHolder h, int pos) { - DownloadMission ms = mItemList.get(pos); - h.mission = ms; - h.position = pos; + @SuppressLint("SetTextI18n") + public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { + DownloadManager.MissionItem item = mIterator.getItem(pos); - Utility.FileType type = Utility.getFileType(ms.name); + if (view instanceof ViewHolderHeader) { + if (item.special == DownloadManager.SPECIAL_NOTHING) return; + int str; + if (item.special == DownloadManager.SPECIAL_PENDING) { + str = R.string.missions_header_pending; + } else { + str = R.string.missions_header_finished; + setClearButtonVisibility(true); + } + + ((ViewHolderHeader) view).header.setText(str); + return; + } + + ViewHolderItem h = (ViewHolderItem) view; + h.item = item; + + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.name); h.icon.setImageResource(Utility.getIconForFileType(type)); - h.name.setText(ms.name); - h.size.setText(Utility.formatBytes(ms.length)); + h.name.setText(item.mission.name); - h.progress = new ProgressDrawable(mContext, Utility.getBackgroundForFileType(type), Utility.getForegroundForFileType(type)); - ViewCompat.setBackground(h.bkg, h.progress); + h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); - h.observer = new MissionObserver(this, h); - ms.addListener(h.observer); + if (h.item.mission instanceof DownloadMission) { + DownloadMission mission = (DownloadMission) item.mission; + String length = Utility.formatBytes(mission.getLength()); + if (mission.running && !mission.postprocessingRunning) length += " --.- kB/s"; - updateProgress(h); + 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 { + h.progress.setMarquee(false); + h.status.setText("100%"); + h.progress.setProgress(1f); + h.size.setText(Utility.formatBytes(item.mission.length)); + } } @Override public int getItemCount() { - return mItemList.size(); + return mIterator.getOldListSize(); } @Override - public long getItemId(int position) { - return position; + public int getItemViewType(int position) { + return mIterator.getSpecialAtItem(position); } - private void updateProgress(ViewHolder h) { - updateProgress(h, false); - } - - private void updateProgress(ViewHolder h, boolean finished) { - if (h.mission == null) return; + @SuppressLint("DefaultLocale") + 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.lastTimeStamp == -1) { + if (h.lastCurrent != mission.current) { + h.lastCurrent = mission.current; h.lastTimeStamp = now; - } - - if (h.lastDone == -1) { - h.lastDone = h.mission.done; + 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 = h.mission.done - h.lastDone; + long deltaDone = mission.done - h.lastDone; + boolean hasError = mission.errCode != ERROR_NOTHING; - if (deltaTime == 0 || deltaTime > 1000 || finished) { - if (h.mission.errCode > 0) { - h.status.setText(R.string.msg_error); - } else { - float progress = (float) h.mission.done / h.mission.length; - h.status.setText(String.format(Locale.US, "%.2f%%", progress * 100)); - h.progress.setProgress(progress); + // on error hide marquee or show if condition (mission.done < 1 || mission.unknownLength) is true + h.progress.setMarquee(!hasError && (mission.done < 1 || mission.unknownLength)); + + float progress; + if (mission.unknownLength) { + progress = Float.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); } } - if (deltaTime > 1000 && deltaDone > 0) { - float speed = (float) deltaDone / deltaTime; - String speedStr = Utility.formatSpeed(speed * 1000); - String sizeStr = Utility.formatBytes(h.mission.length); + if (hasError) { + if (Float.isNaN(progress) || Float.isInfinite(progress)) + h.progress.setProgress(1f); + h.status.setText(R.string.msg_error); + } else if (Float.isNaN(progress) || Float.isInfinite(progress)) { + h.status.setText(UNDEFINED_SPEED); + } else { + h.status.setText(String.format("%.2f%%", progress * 100)); + h.progress.setProgress(progress); + } - h.size.setText(sizeStr + " " + speedStr); + long length = mission.getLength(); + + int state; + if (mission.errCode == ERROR_POSTPROCESSING_FAILED) { + state = 0; + } else if (!mission.running) { + state = mission.enqueued ? 1 : 2; + } else if (mission.postprocessingRunning) { + state = 3; + } else { + state = 0; + } + + if (state != 0) { + // update state without download speed + if (h.state != state) { + String statusStr; + h.state = state; + + 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; + } + + h.size.setText(Utility.formatBytes(length).concat(" (").concat(statusStr).concat(")")); + } else if (deltaDone > 0) { + h.lastTimeStamp = now; + h.lastDone = mission.done; + } + + return; + } + + if (deltaDone > 0 && deltaTime > 0) { + float speed = (deltaDone * 1000f) / deltaTime; + + String speedStr = Utility.formatSpeed(speed); + String sizeStr = Utility.formatBytes(length); + + h.size.setText(sizeStr.concat(" ").concat(speedStr)); h.lastTimeStamp = now; - h.lastDone = h.mission.done; + h.lastDone = mission.done; } } + private boolean viewWithFileProvider(@NonNull File file) { + if (!file.exists()) return true; - private void buildPopup(final ViewHolder h) { - PopupMenu popup = new PopupMenu(mContext, h.menu); - popup.inflate(R.menu.mission); + String ext = Utility.getFileExt(file.getName()); + if (ext == null) return false; - Menu menu = popup.getMenu(); - MenuItem start = menu.findItem(R.id.start); - MenuItem pause = menu.findItem(R.id.pause); - MenuItem view = menu.findItem(R.id.view); - MenuItem delete = menu.findItem(R.id.delete); - MenuItem checksum = menu.findItem(R.id.checksum); + String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - // Set to false first - start.setVisible(false); - pause.setVisible(false); - view.setVisible(false); - delete.setVisible(false); - checksum.setVisible(false); + Uri uri = FileProvider.getUriForFile(mContext, BuildConfig.APPLICATION_ID + ".provider", file); - if (!h.mission.finished) { - if (!h.mission.running) { - if (h.mission.errCode == -1) { - start.setVisible(true); - } - - delete.setVisible(true); - } else { - pause.setVisible(true); - } - } else { - view.setVisible(true); - delete.setVisible(true); - checksum.setVisible(true); - } - - popup.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - int id = item.getItemId(); - switch (id) { - case R.id.start: - mDownloadManager.resumeMission(h.position); - mBinder.onMissionAdded(mItemList.get(h.position)); - return true; - case R.id.pause: - mDownloadManager.pauseMission(h.position); - mBinder.onMissionRemoved(mItemList.get(h.position)); - h.lastTimeStamp = -1; - h.lastDone = -1; - return true; - case R.id.view: - File f = new File(h.mission.location, h.mission.name); - String ext = Utility.getFileExt(h.mission.name); - - Log.d(TAG, "Viewing file: " + f.getAbsolutePath() + " ext: " + ext); - - if (ext == null) { - Log.w(TAG, "Can't view file because it has no extension: " + - h.mission.name); - return false; - } - - String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); - Log.v(TAG, "Mime: " + mime + " package: " + mContext.getApplicationContext().getPackageName() + ".provider"); - if (f.exists()) { - viewFileWithFileProvider(f, mime); - } else { - Log.w(TAG, "File doesn't exist"); - } - - return true; - case R.id.delete: - mDeleteDownloadManager.add(h.mission); - updateItemList(); - notifyDataSetChanged(); - return true; - case R.id.md5: - case R.id.sha1: - DownloadMission mission = mItemList.get(h.position); - new ChecksumTask(mContext).execute(mission.location + "/" + mission.name, ALGORITHMS.get(id)); - return true; - default: - return false; - } - } - }); - - popup.show(); - } - - private void viewFileWithFileProvider(File file, String mimetype) { - String ourPackage = mContext.getApplicationContext().getPackageName(); - Uri uri = FileProvider.getUriForFile(mContext, ourPackage + ".provider", file); Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimetype); + intent.setDataAndType(uri, mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); @@ -300,75 +330,396 @@ public class MissionAdapter extends RecyclerView.Adapter= 100 && mission.errCode < 600) { + str = new StringBuilder(8); + str.append("HTTP "); + str.append(mission.errCode); + } else if (mission.errObject == null) { + str.append("(not_decelerated_error_code)"); + } + break; } - @Override - public void onProgressUpdate(DownloadMission downloadMission, long done, long total) { - mAdapter.updateProgress(mHolder); + if (mission.errObject != null) { + str.append("\n\n"); + str.append(mission.errObject.toString()); } - @Override - public void onFinish(DownloadMission downloadMission) { - //mAdapter.mManager.deleteMission(mHolder.position); - // TODO Notification - //mAdapter.notifyDataSetChanged(); - if (mHolder.mission != null) { - mHolder.size.setText(Utility.formatBytes(mHolder.mission.length)); - mAdapter.updateProgress(mHolder, true); + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + builder.setTitle(mission.name) + .setMessage(str) + .setNegativeButton(android.R.string.ok, (dialog, which) -> dialog.cancel()) + .create() + .show(); + } + + public void clearFinishedDownloads() { + mDownloadManager.forgetFinishedDownloads(); + applyChanges(); + setClearButtonVisibility(false); + } + + private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { + int id = option.getItemId(); + DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; + + if (mission != null) { + switch (id) { + case R.id.start: + 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); + return true; + case R.id.queue: + h.queue.setChecked(!h.queue.isChecked()); + mission.enqueued = h.queue.isChecked(); + updateProgress(h); + return true; } } - @Override - public void onError(DownloadMission downloadMission, int errCode) { - mAdapter.updateProgress(mHolder); + switch (id) { + case R.id.open: + return viewWithFileProvider(h.item.mission.getDownloadedFile()); + case R.id.delete: + if (mDeleter == null) { + mDownloadManager.deleteMission(h.item.mission); + } else { + mDeleter.append(h.item.mission); + } + applyChanges(); + return true; + case R.id.md5: + case R.id.sha1: + new ChecksumTask(mContext).execute(h.item.mission.getDownloadedFile().getAbsolutePath(), ALGORITHMS.get(id)); + return true; + case R.id.source: + /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); + mContext.startActivity(intent);*/ + try { + Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); + intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); + mContext.startActivity(intent); + } catch (Exception e) { + Log.w(TAG, "Selected item has a invalid source", e); + } + return true; + default: + return false; } - } - private static class ChecksumTask extends AsyncTask { - ProgressDialog prog; - final WeakReference weakReference; + public void applyChanges() { + mIterator.start(); + DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); + mIterator.end(); - ChecksumTask(@NonNull Activity activity) { - weakReference = new WeakReference<>(activity); + checkEmptyMessageVisibility(); + + if (mIterator.getOldListSize() > 0) { + int lastItemType = mIterator.getSpecialAtItem(mIterator.getOldListSize() - 1); + setClearButtonVisibility(lastItemType == DownloadManager.SPECIAL_FINISHED); + } + } + + public void forceUpdate() { + mIterator.start(); + mIterator.end(); + + for (ViewHolderItem item : mPendingDownloadsItems) { + item.lastTimeStamp = -1; + } + + notifyDataSetChanged(); + } + + public void setLinear(boolean isLinear) { + mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; + } + + public void setClearButton(MenuItem clearButton) { + if (mClear == null) { + int lastItemType = mIterator.getSpecialAtItem(mIterator.getOldListSize() - 1); + clearButton.setVisible(lastItemType == DownloadManager.SPECIAL_FINISHED); + } + mClear = clearButton; + } + + private void setClearButtonVisibility(boolean flag) { + mClear.setVisible(flag); + } + + private void checkEmptyMessageVisibility() { + int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; + if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); + } + + + public void deleterDispose(Bundle bundle) { + if (mDeleter != null) mDeleter.dispose(bundle); + } + + public void deleterLoad(Bundle bundle, View view) { + if (mDeleter == null) + mDeleter = new Deleter(bundle, view, mContext, this, mDownloadManager, mIterator, mHandler); + } + + public void deleterResume() { + if (mDeleter != null) mDeleter.resume(); + } + + + private boolean mUpdaterRunning = false; + private final Runnable rUpdater = this::updater; + + public void onPaused() { + setAutoRefresh(false); + } + + private void setAutoRefresh(boolean enabled) { + if (enabled && !mUpdaterRunning) { + mUpdaterRunning = true; + updater(); + } else if (!enabled && mUpdaterRunning) { + mUpdaterRunning = false; + mHandler.removeCallbacks(rUpdater); + } + } + + 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; + } + } + + + class ViewHolderItem extends RecyclerView.ViewHolder { + DownloadManager.MissionItem item; + + TextView status; + ImageView icon; + TextView name; + TextView size; + ProgressDrawable progress; + + PopupMenu popupMenu; + MenuItem start; + MenuItem pause; + MenuItem open; + MenuItem queue; + MenuItem showError; + MenuItem delete; + MenuItem source; + MenuItem checksum; + + long lastTimeStamp = -1; + long lastDone = -1; + int lastCurrent = -1; + int state = 0; + + ViewHolderItem(View view) { + super(view); + + progress = new ProgressDrawable(); + ViewCompat.setBackground(itemView.findViewById(R.id.item_bkg), progress); + + status = itemView.findViewById(R.id.item_status); + name = itemView.findViewById(R.id.item_name); + icon = itemView.findViewById(R.id.item_icon); + size = itemView.findViewById(R.id.item_size); + + name.setSelected(true); + + ImageView button = itemView.findViewById(R.id.item_more); + popupMenu = buildPopup(button); + button.setOnClickListener(v -> showPopupMenu()); + + Menu menu = popupMenu.getMenu(); + start = menu.findItem(R.id.start); + pause = menu.findItem(R.id.pause); + open = menu.findItem(R.id.open); + queue = menu.findItem(R.id.queue); + showError = menu.findItem(R.id.error_message_view); + delete = menu.findItem(R.id.delete); + source = menu.findItem(R.id.source); + checksum = menu.findItem(R.id.checksum); + + itemView.setOnClickListener((v) -> { + if (item.mission instanceof FinishedMission) + viewWithFileProvider(item.mission.getDownloadedFile()); + }); + } + + private void showPopupMenu() { + start.setVisible(false); + pause.setVisible(false); + open.setVisible(false); + queue.setVisible(false); + showError.setVisible(false); + delete.setVisible(false); + source.setVisible(false); + checksum.setVisible(false); + + DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; + + if (mission != null) { + if (!mission.postprocessingRunning) { + if (mission.running) { + pause.setVisible(true); + } else { + if (mission.errCode != ERROR_NOTHING) { + showError.setVisible(true); + } + + queue.setChecked(mission.enqueued); + + delete.setVisible(true); + start.setVisible(mission.errCode != ERROR_POSTPROCESSING_FAILED); + queue.setVisible(mission.errCode != ERROR_POSTPROCESSING_FAILED); + } + } + } else { + open.setVisible(true); + delete.setVisible(true); + checksum.setVisible(true); + } + + if (item.mission.source != null && !item.mission.source.isEmpty()) { + source.setVisible(true); + } + + popupMenu.show(); + } + + private PopupMenu buildPopup(final View button) { + PopupMenu popup = new PopupMenu(mContext, button); + popup.inflate(R.menu.mission); + popup.setOnMenuItemClickListener(option -> handlePopupItem(this, option)); + + return popup; + } + } + + class ViewHolderHeader extends RecyclerView.ViewHolder { + TextView header; + + ViewHolderHeader(View view) { + super(view); + header = itemView.findViewById(R.id.item_name); + } + } + + + static class ChecksumTask extends AsyncTask { + ProgressDialog progressDialog; + WeakReference weakReference; + + ChecksumTask(@NonNull Context context) { + weakReference = new WeakReference<>((Activity) context); } @Override @@ -378,10 +729,10 @@ public class MissionAdapter extends RecyclerView.Adapter items; + private boolean running = true; + + private Context mContext; + private MissionAdapter mAdapter; + private DownloadManager mDownloadManager; + private MissionIterator mIterator; + private Handler mHandler; + private View mView; + + private final Runnable rShow; + private final Runnable rNext; + private final Runnable rCommit; + + public Deleter(Bundle b, View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { + mView = v; + mContext = c; + mAdapter = a; + mDownloadManager = d; + mIterator = i; + mHandler = h; + + // use variables to know the reference of the lambdas + rShow = this::show; + rNext = this::next; + rCommit = this::commit; + + items = new ArrayList<>(2); + + if (b != null) { + String[] names = b.getStringArray(BUNDLE_NAMES); + String[] locations = b.getStringArray(BUNDLE_LOCATIONS); + + if (names == null || locations == null) return; + if (names.length < 1 || locations.length < 1) return; + if (names.length != locations.length) return; + + items.ensureCapacity(names.length); + + for (int j = 0; j < locations.length; j++) { + Mission mission = mDownloadManager.getAnyMission(locations[j], names[j]); + if (mission == null) continue; + + items.add(mission); + mIterator.hide(mission); + } + + if (items.size() > 0) resume(); + } + } + + public void append(Mission item) { + mIterator.hide(item); + items.add(0, item); + + show(); + } + + private void forget() { + mIterator.unHide(items.remove(0)); + mAdapter.applyChanges(); + + show(); + } + + private void show() { + if (items.size() < 1) return; + + pause(); + running = true; + + mHandler.postDelayed(rNext, DELAY); + } + + private void next() { + if (items.size() < 1) return; + + String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).name); + + snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); + snackbar.setAction(R.string.undo, s -> forget()); + snackbar.setActionTextColor(Color.YELLOW); + snackbar.show(); + + mHandler.postDelayed(rCommit, TIMEOUT); + } + + private void commit() { + if (items.size() < 1) return; + + while (items.size() > 0) { + Mission mission = items.remove(0); + if (mission.deleted) continue; + + mIterator.unHide(mission); + mDownloadManager.deleteMission(mission); + + if (mission instanceof FinishedMission) { + mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(mission.getDownloadedFile()))); + } + break; + } + + if (items.size() < 1) { + pause(); + return; + } + + show(); + } + + private void pause() { + running = false; + mHandler.removeCallbacks(rNext); + mHandler.removeCallbacks(rShow); + mHandler.removeCallbacks(rCommit); + if (snackbar != null) snackbar.dismiss(); + } + + public void resume() { + if (running) return; + mHandler.postDelayed(rShow, DELAY_RESUME); + } + + public void dispose(Bundle bundle) { + if (items.size() < 1) return; + + pause(); + + if (bundle == null) { + for (Mission mission : items) mDownloadManager.deleteMission(mission); + items = null; + return; + } + + String[] names = new String[items.size()]; + String[] locations = new String[items.size()]; + + for (int i = 0; i < items.size(); i++) { + Mission mission = items.get(i); + names[i] = mission.name; + locations[i] = mission.location; + } + + bundle.putStringArray(BUNDLE_NAMES, names); + bundle.putStringArray(BUNDLE_LOCATIONS, locations); + } +} 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 955ce4c65..33eba22eb 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 @@ -1,25 +1,36 @@ package us.shandian.giga.ui.common; -import android.content.Context; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; +import android.graphics.Path; import android.graphics.PixelFormat; +import android.graphics.Rect; import android.graphics.drawable.Drawable; -import android.support.annotation.ColorRes; +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.ColorInt; import android.support.annotation.NonNull; -import android.support.v4.content.ContextCompat; public class ProgressDrawable extends Drawable { - private float mProgress; - private final int mBackgroundColor; - private final int mForegroundColor; + private static final int MARQUEE_INTERVAL = 150; - public ProgressDrawable(Context context, @ColorRes int background, @ColorRes int foreground) { - this(ContextCompat.getColor(context, background), ContextCompat.getColor(context, foreground)); + private float mProgress; + private int mBackgroundColor, mForegroundColor; + private Handler mMarqueeHandler; + private float mMarqueeProgress; + private Path mMarqueeLine; + private int mMarqueeSize; + private long mMarqueeNext; + + public ProgressDrawable() { + mMarqueeLine = null;// marquee disabled + mMarqueeProgress = 0f; + mMarqueeSize = 0; + mMarqueeNext = 0; } - public ProgressDrawable(int background, int foreground) { + public void setColors(@ColorInt int background, @ColorInt int foreground) { mBackgroundColor = background; mForegroundColor = foreground; } @@ -29,10 +40,20 @@ public class ProgressDrawable extends Drawable { invalidateSelf(); } + public void setMarquee(boolean marquee) { + if (marquee == (mMarqueeLine != null)) { + return; + } + mMarqueeLine = marquee ? new Path() : null; + mMarqueeHandler = marquee ? new Handler(Looper.getMainLooper()) : null; + mMarqueeSize = 0; + mMarqueeNext = 0; + } + @Override public void draw(@NonNull Canvas canvas) { - int width = canvas.getWidth(); - int height = canvas.getHeight(); + int width = getBounds().width(); + int height = getBounds().height(); Paint paint = new Paint(); @@ -40,6 +61,42 @@ public class ProgressDrawable extends Drawable { canvas.drawRect(0, 0, width, height, paint); paint.setColor(mForegroundColor); + + if (mMarqueeLine != null) { + if (mMarqueeSize < 1) setupMarquee(width, height); + + int size = mMarqueeSize; + Paint paint2 = new Paint(); + paint2.setColor(mForegroundColor); + paint2.setStrokeWidth(size); + paint2.setStyle(Paint.Style.STROKE); + + size *= 2; + + if (mMarqueeProgress >= size) { + mMarqueeProgress = 1; + } else { + mMarqueeProgress++; + } + + // render marquee + width += size * 2; + Path marquee = new Path(); + for (float i = -size; i < width; i += size) { + marquee.addPath(mMarqueeLine, i + mMarqueeProgress, 0); + } + marquee.close(); + + canvas.drawPath(marquee, paint2);// draw marquee + + if (System.currentTimeMillis() >= mMarqueeNext) { + // program next update + mMarqueeNext = System.currentTimeMillis() + MARQUEE_INTERVAL; + mMarqueeHandler.postDelayed(this::invalidateSelf, MARQUEE_INTERVAL); + } + return; + } + canvas.drawRect(0, 0, (int) (mProgress * width), height, paint); } @@ -58,4 +115,17 @@ public class ProgressDrawable extends Drawable { return PixelFormat.OPAQUE; } + @Override + public void onBoundsChange(Rect rect) { + if (mMarqueeLine != null) setupMarquee(rect.width(), rect.height()); + } + + private void setupMarquee(int width, int height) { + mMarqueeSize = (int) ((width * 10f) / 100f);// the size is 10% of the width + + mMarqueeLine.rewind(); + mMarqueeLine.moveTo(-mMarqueeSize, -mMarqueeSize); + mMarqueeLine.lineTo(-mMarqueeSize * 4, height + mMarqueeSize); + mMarqueeLine.close(); + } } diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/AllMissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/AllMissionsFragment.java deleted file mode 100644 index ec8d7fc22..000000000 --- a/app/src/main/java/us/shandian/giga/ui/fragment/AllMissionsFragment.java +++ /dev/null @@ -1,12 +0,0 @@ -package us.shandian.giga.ui.fragment; - -import us.shandian.giga.get.DownloadManager; -import us.shandian.giga.service.DownloadManagerService; - -public class AllMissionsFragment extends MissionsFragment { - - @Override - protected DownloadManager setupDownloadManager(DownloadManagerService.DMBinder binder) { - return binder.getDownloadManager(); - } -} 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 7b42f90c9..aa9c497f1 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 @@ -10,50 +10,58 @@ import android.content.SharedPreferences; import android.os.Bundle; import android.os.IBinder; import android.preference.PreferenceManager; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; import android.support.v7.widget.GridLayoutManager; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; +import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.R; -import org.schabi.newpipe.download.DeleteDownloadManager; -import io.reactivex.disposables.Disposable; -import us.shandian.giga.get.DownloadManager; +import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.service.DownloadManagerService.DMBinder; import us.shandian.giga.ui.adapter.MissionAdapter; -public abstract class MissionsFragment extends Fragment { - private DownloadManager mDownloadManager; - private DownloadManagerService.DMBinder mBinder; +public class MissionsFragment extends Fragment { + + private static final int SPAN_SIZE = 2; private SharedPreferences mPrefs; private boolean mLinear; private MenuItem mSwitch; + private MenuItem mClear = null; private RecyclerView mList; + private View mEmpty; private MissionAdapter mAdapter; private GridLayoutManager mGridManager; private LinearLayoutManager mLinearManager; private Context mActivity; - private DeleteDownloadManager mDeleteDownloadManager; - private Disposable mDeleteDisposable; - private final ServiceConnection mConnection = new ServiceConnection() { + private DMBinder mBinder; + private Bundle mBundle; + private boolean mForceUpdate; + + private ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { mBinder = (DownloadManagerService.DMBinder) binder; - mDownloadManager = setupDownloadManager(mBinder); - if (mDeleteDownloadManager != null) { - mDeleteDownloadManager.setDownloadManager(mDownloadManager); - updateList(); - } + mBinder.clearDownloadNotifications(); + + mAdapter = new MissionAdapter(mActivity, mBinder.getDownloadManager(), mClear, mEmpty); + mAdapter.deleterLoad(mBundle, getView()); + + mBundle = null; + + mBinder.addMissionEventListener(mAdapter.getMessenger()); + mBinder.enableNotifications(false); + + updateList(); } @Override @@ -64,14 +72,6 @@ public abstract class MissionsFragment extends Fragment { }; - public void setDeleteManager(@NonNull DeleteDownloadManager deleteDownloadManager) { - mDeleteDownloadManager = deleteDownloadManager; - if (mDownloadManager != null) { - mDeleteDownloadManager.setDownloadManager(mDownloadManager); - updateList(); - } - } - @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.missions, container, false); @@ -79,18 +79,32 @@ public abstract class MissionsFragment extends Fragment { mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); mLinear = mPrefs.getBoolean("linear", false); + mActivity = getActivity(); + mBundle = savedInstanceState; + // Bind the service - Intent i = new Intent(); - i.setClass(getActivity(), DownloadManagerService.class); - getActivity().bindService(i, mConnection, Context.BIND_AUTO_CREATE); + mActivity.bindService(new Intent(mActivity, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); // Views + mEmpty = v.findViewById(R.id.list_empty_view); mList = v.findViewById(R.id.mission_recycler); // Init - mGridManager = new GridLayoutManager(getActivity(), 2); + mGridManager = new GridLayoutManager(getActivity(), SPAN_SIZE); + mGridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(int position) { + switch (mAdapter.getItemViewType(position)) { + case DownloadManager.SPECIAL_PENDING: + case DownloadManager.SPECIAL_FINISHED: + return SPAN_SIZE; + default: + return 1; + } + } + }); + mLinearManager = new LinearLayoutManager(getActivity()); - mList.setLayoutManager(mGridManager); setHasOptionsMenu(true); @@ -121,63 +135,98 @@ public abstract class MissionsFragment extends Fragment { mActivity = activity; } + @Override - public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - if (mDeleteDownloadManager != null) { - mDeleteDisposable = mDeleteDownloadManager.getUndoObservable().subscribe(mission -> { - if (mAdapter != null) { - mAdapter.updateItemList(); - mAdapter.notifyDataSetChanged(); - } - }); - } + public void onDestroy() { + super.onDestroy(); + if (mBinder == null || mAdapter == null) return; + + mBinder.removeMissionEventListener(mAdapter.getMessenger()); + mBinder.enableNotifications(true); + mActivity.unbindService(mConnection); + mAdapter.deleterDispose(null); + + mBinder = null; + mAdapter = null; } @Override - public void onDestroyView() { - super.onDestroyView(); - getActivity().unbindService(mConnection); - if (mDeleteDisposable != null) { - mDeleteDisposable.dispose(); - } + public void onPrepareOptionsMenu(Menu menu) { + mSwitch = menu.findItem(R.id.switch_mode); + mClear = menu.findItem(R.id.clear_list); + if (mAdapter != null) mAdapter.setClearButton(mClear); + super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { - return super.onOptionsItemSelected(item); - - /*switch (item.getItemId()) { + switch (item.getItemId()) { case R.id.switch_mode: - mLinear = !mLinear; - updateList(); - return true; - default: - return super.onOptionsItemSelected(item); - }*/ - } - - public void notifyChange() { - mAdapter.notifyDataSetChanged(); + mLinear = !mLinear; + updateList(); + return true; + case R.id.clear_list: + mAdapter.clearFinishedDownloads(); + return true; + default: + return super.onOptionsItemSelected(item); + } } private void updateList() { - mAdapter = new MissionAdapter((Activity) mActivity, mBinder, mDownloadManager, mDeleteDownloadManager, mLinear); - if (mLinear) { mList.setLayoutManager(mLinearManager); } else { mList.setLayoutManager(mGridManager); } + // destroy all created views in the recycler + mList.setAdapter(null); + mAdapter.notifyDataSetChanged(); + + // re-attach the adapter in grid/lineal mode + mAdapter.setLinear(mLinear); mList.setAdapter(mAdapter); if (mSwitch != null) { mSwitch.setIcon(mLinear ? R.drawable.grid : R.drawable.list); + mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); + mPrefs.edit().putBoolean("linear", mLinear).apply(); } - - mPrefs.edit().putBoolean("linear", mLinear).apply(); } - protected abstract DownloadManager setupDownloadManager(DownloadManagerService.DMBinder binder); + @Override + public void onSaveInstanceState(Bundle outState) { + super.onSaveInstanceState(outState); + + if (mAdapter != null) { + mAdapter.deleterDispose(outState); + mForceUpdate = true; + mBinder.removeMissionEventListener(mAdapter.getMessenger()); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (mAdapter != null) { + mAdapter.deleterResume(); + + if (mForceUpdate) { + mForceUpdate = false; + mAdapter.forceUpdate(); + } + + mBinder.addMissionEventListener(mAdapter.getMessenger()); + } + if (mBinder != null) mBinder.enableNotifications(false); + } + + @Override + public void onPause() { + super.onPause(); + if (mAdapter != null) 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 163ac2b14..e5149cf9b 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -3,15 +3,18 @@ package us.shandian.giga.util; import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; -import android.support.annotation.ColorRes; +import android.os.Build; +import android.support.annotation.ColorInt; import android.support.annotation.DrawableRes; import android.support.annotation.NonNull; import android.support.annotation.Nullable; +import android.support.v4.content.ContextCompat; import android.widget.Toast; import org.schabi.newpipe.R; import java.io.BufferedOutputStream; +import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; @@ -19,14 +22,17 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; +import java.net.HttpURLConnection; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Locale; public class Utility { public enum FileType { VIDEO, MUSIC, + SUBTITLE, UNKNOWN } @@ -34,11 +40,11 @@ public class Utility { if (bytes < 1024) { return String.format("%d B", bytes); } else if (bytes < 1024 * 1024) { - return String.format("%.2f kB", (float) bytes / 1024); + return String.format("%.2f kB", bytes / 1024d); } else if (bytes < 1024 * 1024 * 1024) { - return String.format("%.2f MB", (float) bytes / 1024 / 1024); + return String.format("%.2f MB", bytes / 1024d / 1024d); } else { - return String.format("%.2f GB", (float) bytes / 1024 / 1024 / 1024); + return String.format("%.2f GB", bytes / 1024d / 1024d / 1024d); } } @@ -54,41 +60,32 @@ public class Utility { } } - public static void writeToFile(@NonNull String fileName, @NonNull Serializable serializable) { - ObjectOutputStream objectOutputStream = null; + public static void writeToFile(@NonNull File file, @NonNull Serializable serializable) { - try { - objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(fileName))); + try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { objectOutputStream.writeObject(serializable); } catch (Exception e) { //nothing to do - } finally { - if(objectOutputStream != null) { - try { - objectOutputStream.close(); - } catch (Exception e) { - //nothing to do - } - } } + //nothing to do } @Nullable @SuppressWarnings("unchecked") - public static T readFromFile(String file) { - T object = null; + public static T readFromFile(File file) { + T object; ObjectInputStream objectInputStream = null; try { objectInputStream = new ObjectInputStream(new FileInputStream(file)); object = (T) objectInputStream.readObject(); } catch (Exception e) { - //nothing to do + object = null; } - if(objectInputStream != null){ + if (objectInputStream != null) { try { - objectInputStream .close(); + objectInputStream.close(); } catch (Exception e) { //nothing to do } @@ -119,39 +116,68 @@ public class Utility { } } - public static FileType getFileType(String file) { - if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a")) { + public static FileType getFileType(char kind, String file) { + switch (kind) { + case 'v': + return FileType.VIDEO; + case 'a': + return FileType.MUSIC; + case 's': + return FileType.SUBTITLE; + //default '?': + } + + if (file.endsWith(".srt") || file.endsWith(".vtt") || file.endsWith(".ssa")) { + return FileType.SUBTITLE; + } else if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a") || file.endsWith(".opus")) { return FileType.MUSIC; } else if (file.endsWith(".mp4") || file.endsWith(".mpeg") || file.endsWith(".rm") || file.endsWith(".rmvb") || file.endsWith(".flv") || file.endsWith(".webp") || file.endsWith(".webm")) { return FileType.VIDEO; - } else { - return FileType.UNKNOWN; } + + return FileType.UNKNOWN; } - @ColorRes - public static int getBackgroundForFileType(FileType type) { + @ColorInt + public static int getBackgroundForFileType(Context ctx, FileType type) { + int colorRes; switch (type) { case MUSIC: - return R.color.audio_left_to_load_color; + colorRes = R.color.audio_left_to_load_color; + break; case VIDEO: - return R.color.video_left_to_load_color; + colorRes = R.color.video_left_to_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_left_to_load_color; + break; default: - return R.color.gray; + colorRes = R.color.gray; } + + return ContextCompat.getColor(ctx, colorRes); } - @ColorRes - public static int getForegroundForFileType(FileType type) { + @ColorInt + public static int getForegroundForFileType(Context ctx, FileType type) { + int colorRes; switch (type) { case MUSIC: - return R.color.audio_already_load_color; + colorRes = R.color.audio_already_load_color; + break; case VIDEO: - return R.color.video_already_load_color; + colorRes = R.color.video_already_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_already_load_color; + break; default: - return R.color.gray; + colorRes = R.color.gray; + break; } + + return ContextCompat.getColor(ctx, colorRes); } @DrawableRes @@ -161,6 +187,8 @@ public class Utility { return R.drawable.music; case VIDEO: return R.drawable.video; + case SUBTITLE: + return R.drawable.subtitle; default: return R.drawable.video; } @@ -168,12 +196,18 @@ public class Utility { public static void copyToClipboard(Context context, String str) { ClipboardManager cm = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (cm == null) { + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); + return; + } + cm.setPrimaryClip(ClipData.newPlainText("text", str)); Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } public static String checksum(String path, String algorithm) { - MessageDigest md = null; + MessageDigest md; try { md = MessageDigest.getInstance(algorithm); @@ -181,7 +215,7 @@ public class Utility { throw new RuntimeException(e); } - FileInputStream i = null; + FileInputStream i; try { i = new FileInputStream(path); @@ -190,14 +224,14 @@ public class Utility { } byte[] buf = new byte[1024]; - int len = 0; + int len; try { while ((len = i.read(buf)) != -1) { md.update(buf, 0, len); } - } catch (IOException ignored) { - + } catch (IOException e) { + // nothing to do } byte[] digest = md.digest(); @@ -211,4 +245,31 @@ public class Utility { return sb.toString(); } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static boolean mkdir(File path, boolean allDirs) { + if (path.exists()) return true; + + if (allDirs) + path.mkdirs(); + else + path.mkdir(); + + return path.exists(); + } + + public static long getContentLength(HttpURLConnection connection) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return connection.getContentLengthLong(); + } + + try { + long length = Long.parseLong(connection.getHeaderField("Content-Length")); + if (length >= 0) return length; + } catch (Exception err) { + // nothing to do + } + + return -1; + } } diff --git a/app/src/main/res/drawable-hdpi/grid.png b/app/src/main/res/drawable-hdpi/grid.png index 254f1d300..26fa36c07 100644 Binary files a/app/src/main/res/drawable-hdpi/grid.png and b/app/src/main/res/drawable-hdpi/grid.png differ diff --git a/app/src/main/res/drawable-hdpi/list.png b/app/src/main/res/drawable-hdpi/list.png index 0b3f54c20..16da863e2 100644 Binary files a/app/src/main/res/drawable-hdpi/list.png and b/app/src/main/res/drawable-hdpi/list.png differ diff --git a/app/src/main/res/drawable-xhdpi/subtitle.png b/app/src/main/res/drawable-xhdpi/subtitle.png new file mode 100644 index 000000000..7f535288e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/subtitle.png differ diff --git a/app/src/main/res/drawable/default_dot.xml b/app/src/main/res/drawable/default_dot.xml new file mode 100644 index 000000000..3380dca3b --- /dev/null +++ b/app/src/main/res/drawable/default_dot.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selected_dot.xml b/app/src/main/res/drawable/selected_dot.xml new file mode 100644 index 000000000..017e99d43 --- /dev/null +++ b/app/src/main/res/drawable/selected_dot.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/tab_selector.xml b/app/src/main/res/drawable/tab_selector.xml new file mode 100644 index 000000000..b7307674b --- /dev/null +++ b/app/src/main/res/drawable/tab_selector.xml @@ -0,0 +1,8 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-large-land/activity_main_player.xml b/app/src/main/res/layout-large-land/activity_main_player.xml new file mode 100644 index 000000000..7d7e1230e --- /dev/null +++ b/app/src/main/res/layout-large-land/activity_main_player.xml @@ -0,0 +1,581 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-large-land/fragment_video_detail.xml b/app/src/main/res/layout-large-land/fragment_video_detail.xml new file mode 100644 index 000000000..2df22ab48 --- /dev/null +++ b/app/src/main/res/layout-large-land/fragment_video_detail.xml @@ -0,0 +1,498 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 2cdfee553..985ce03f5 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -53,6 +53,12 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/audio"/> + + + android:layout_alignParentTop="true" + android:visibility="gone"/> diff --git a/app/src/main/res/layout/fragment_related_streams.xml b/app/src/main/res/layout/fragment_related_streams.xml index 36c9c1d31..c12630392 100644 --- a/app/src/main/res/layout/fragment_related_streams.xml +++ b/app/src/main/res/layout/fragment_related_streams.xml @@ -65,6 +65,7 @@ android:layout_width="match_parent" android:layout_height="4dp" android:background="?attr/toolbar_shadow_drawable" - android:layout_alignParentTop="true"/> + android:layout_alignParentTop="true" + android:visibility="gone"/> diff --git a/app/src/main/res/layout/fragment_subscription.xml b/app/src/main/res/layout/fragment_subscription.xml index a40059455..f2137074e 100644 --- a/app/src/main/res/layout/fragment_subscription.xml +++ b/app/src/main/res/layout/fragment_subscription.xml @@ -11,7 +11,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:scrollbars="vertical" - app:layoutManager="LinearLayoutManager" android:visibility="gone" tools:listitem="@layout/list_channel_item" tools:visibility="visible"/> diff --git a/app/src/main/res/layout/fragment_video_detail.xml b/app/src/main/res/layout/fragment_video_detail.xml index 25eac35cf..906246bd0 100644 --- a/app/src/main/res/layout/fragment_video_detail.xml +++ b/app/src/main/res/layout/fragment_video_detail.xml @@ -10,12 +10,16 @@ + android:layout_height="match_parent" + android:fitsSystemWindows="true"> + android:layout_height="wrap_content" + android:fitsSystemWindows="true" + app:elevation="0dp" + app:layout_behavior="android.support.design.widget.FlingBehavior"> - - - - + + + + + + diff --git a/app/src/main/res/layout/list_channel_grid_item.xml b/app/src/main/res/layout/list_channel_grid_item.xml new file mode 100644 index 000000000..3fe642974 --- /dev/null +++ b/app/src/main/res/layout/list_channel_grid_item.xml @@ -0,0 +1,48 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/list_comments_item.xml b/app/src/main/res/layout/list_comments_item.xml index 16b6107c5..a9b091329 100644 --- a/app/src/main/res/layout/list_comments_item.xml +++ b/app/src/main/res/layout/list_comments_item.xml @@ -49,17 +49,6 @@ android:textSize="@dimen/video_item_search_uploader_text_size" tools:text="Comment Content, Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" /> - - + + + + diff --git a/app/src/main/res/layout/list_comments_mini_item.xml b/app/src/main/res/layout/list_comments_mini_item.xml index 7e8ce1a87..36f3e2e6e 100644 --- a/app/src/main/res/layout/list_comments_mini_item.xml +++ b/app/src/main/res/layout/list_comments_mini_item.xml @@ -20,27 +20,11 @@ android:src="@drawable/buddy_channel_item" tools:ignore="RtlHardcoded"/> - - - - - + + + + diff --git a/app/src/main/res/layout/list_playlist_grid_item.xml b/app/src/main/res/layout/list_playlist_grid_item.xml new file mode 100644 index 000000000..949b1159b --- /dev/null +++ b/app/src/main/res/layout/list_playlist_grid_item.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_stream_grid_item.xml b/app/src/main/res/layout/list_stream_grid_item.xml new file mode 100644 index 000000000..cf73bf9b1 --- /dev/null +++ b/app/src/main/res/layout/list_stream_grid_item.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/list_stream_playlist_grid_item.xml b/app/src/main/res/layout/list_stream_playlist_grid_item.xml new file mode 100644 index 000000000..4b31a452e --- /dev/null +++ b/app/src/main/res/layout/list_stream_playlist_grid_item.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/mission_item.xml b/app/src/main/res/layout/mission_item.xml index 05e099103..45e4d44e2 100644 --- a/app/src/main/res/layout/mission_item.xml +++ b/app/src/main/res/layout/mission_item.xml @@ -38,7 +38,7 @@ android:layout_centerVertical="true" android:layout_marginRight="1dp" android:src="@drawable/ic_menu_more" - android:scaleType="centerInside" + android:scaleType="center" android:contentDescription="TODO" /> @@ -61,7 +61,9 @@ android:layout_below="@id/item_icon" android:padding="6dp" android:singleLine="true" - android:ellipsize="end" + android:ellipsize="marquee" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" android:text="XXX.xx" android:textSize="16sp" android:textStyle="bold" diff --git a/app/src/main/res/layout/mission_item_linear.xml b/app/src/main/res/layout/mission_item_linear.xml index 21f637839..7fff76235 100644 --- a/app/src/main/res/layout/mission_item_linear.xml +++ b/app/src/main/res/layout/mission_item_linear.xml @@ -9,7 +9,7 @@ android:layout_width="match_parent" android:layout_margin="2dp" android:background="@color/bluegray"> - + - - + android:ellipsize="end" + android:padding="6dp" + android:text="XXX.xx" + android:textColor="@color/white" + android:textSize="16sp" + android:textStyle="bold" /> - - - - - - + + android:textColor="@color/white" + android:textSize="12sp" /> - + + + + + + + + + + + + diff --git a/app/src/main/res/layout/related_streams_header.xml b/app/src/main/res/layout/related_streams_header.xml new file mode 100644 index 000000000..b98244b7e --- /dev/null +++ b/app/src/main/res/layout/related_streams_header.xml @@ -0,0 +1,32 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/download_menu.xml b/app/src/main/res/menu/download_menu.xml index 4cbb845ea..e79367135 100644 --- a/app/src/main/res/menu/download_menu.xml +++ b/app/src/main/res/menu/download_menu.xml @@ -2,7 +2,18 @@ + + - \ No newline at end of file + + + diff --git a/app/src/main/res/menu/mission.xml b/app/src/main/res/menu/mission.xml index b76d1a923..961e12fd0 100644 --- a/app/src/main/res/menu/mission.xml +++ b/app/src/main/res/menu/mission.xml @@ -1,37 +1,48 @@ + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fdb859609..d9bd02a43 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -28,13 +28,13 @@ خطأ في الشبكة الفيديو التالي لا يوجد مشغل فيديو. هل تريد تثبيت VLC ؟ - فتح في المتصفح + افتح في المتصفح الصوت تشغيل - تشغيل بواسطة Kodi + تشغيل بواسطة كودي التدوير بحث - اللغة الإفتراضية للمحتوى + اللغة الإفتراضية للمحتوى الإعدادات المظهر اخرى @@ -42,8 +42,8 @@ مشاركة مشاركة بواسطة عرض الفديوهات \'التالية\'و\'المماثلة\' - عرض خيار لتشغيل الفيديو بواسطة Kodi Media Center - عرض خيار التشغيل بواسطة Kodi + عرض خيار لتشغيل الفيديو بواسطة مشغل كودي + عرض خيار التشغيل بواسطة كودي السمة تم النشر يوم %1$s الرابط غير مدعوم @@ -66,30 +66,30 @@ ما الجديد - الخلفية + في الخلفية تشغيل تلقائي اسود التاريخ وذاكرة التخزين المؤقت - التاريخ و ذاكرة التخزين المؤقتة + التاريخ و التخزين المؤقت المحتوى التنزيلات التنزيلات الكل القناة - فيديو + الفيديو التنزيل عن التطبيق عن التطبيق التاريخ التاريخ - فتح في وضع النافذة المنبثقة + افتح في وضع النافذة المنبثقة "بعض الخيارات الدقة لن تحتوي على صوت عند تمكين هذا الخيار " وضع النوافذ المنبثقة NewPipe تم إلغاء الاشتراك في القناة تعذر تغيير حالة الاشتراك تعذر تحديث الاشتراك - نافذة + نافذة المنبثقة تشغيل مقطع الفيديو عند إستدعاء NewPipe من تطبيق آخر الدقة الافتراضية لنوافذ المنبثقة @@ -107,11 +107,11 @@ تتبع مقاطع الفيديو التي تمت مشاهدتها استئناف عند اكتساب التركيز متابعة التشغيل بعد المقاطعات (مثل المكالمات الهاتفية) - عرض\"عقد لإلحاق\"عنوان - عرض تلميح أو زر منبثق عند الضغط على خلفية على صفحة تفاصيل الفيديو + عرض تلميح \"اضغط للتجاهل\" + عرض تلميح على صفحة التفاصيل عند استخدام وضع مشغل الخلفية أو النافذة المنبثقة المشغل السلوك - المنبثق + الوضع المنبثق يتم التشغيل في الوضع المنبثق تم وضعه على قائمة الانتظار في مشغل الخلفية تم وضعه على قائمة الانتظار في مشغل النافذة المنبثقة @@ -165,8 +165,6 @@ الصوت إعادة المحاولة تم رفض إذن الوصول إلى التخزين - استخدام المشغل القديم - المشغل القديم المدمج في إطار Mediaframework ألف مليون @@ -177,9 +175,9 @@ صفر مشترِك %s مشترِك مشترِكان - %s مشترك + %s مشتركين %s مشترك - %s مشتركون + %s مشترك دون مشاهدات @@ -194,7 +192,7 @@ حسناً اسم الملف - الخيوط + التقسيم الخطأ الخادم غير معتمد الملف موجود مسبقا @@ -208,8 +206,8 @@ \nللفتح في وضع النافذة المنبثقة اختبار reCAPTCHA - الأحرف المسموح بها في أسماء الملفات - يتم استبدال الأحرف غير الصالحة بهذه القيمة + السماح بالرموز في أسماء الملفات + يتم استبدال الرموز غير المسموح بها بهذه القيمة استبدال الحرف الحروف والأرقام @@ -222,7 +220,7 @@ فتح الموقع المساهمون التراخيص - تطبيق حُر و خفيف لالتقاط البث على نظام الأندرويد. + تطبيق حُر و خفيف لتشغيل اليوتيوب على نظام الأندرويد. ساهم إذا كانت لديك أفكار؛ أو ترجمة، أو تغييرات تخص التصميم، أو تنظيف و تحسين الشفرة البرمجية ، أو تعديلات عميقة عليها، فتذكر أنّ مساعدتك دائما موضع ترحيب. وكلما أتممنا شيئا كلما كان ذلك أفضل ! عرض على GitHub @@ -258,17 +256,17 @@ حذف التفاصيل الإعدادات الصوتية - ابدء التشغيل هنا + تشغيل هنا تشغيل هنا في وضع النافذة المنبثقة تحدي ريكابتشا اضغط للإدراج في قائمة الانتظار - لاتوجد مشاهدة + بدون مشاهدات %s مشاهدة مشاهدتين - %s مشاهدات كثيرة - %s عدد المشاهدات - %s أقصى مشاهدات + %s مشاهدات + %s مشاهدات + %s مشاهدة @@ -289,7 +287,7 @@ الكشك إدراج في قائمة الانتظار في الخلفية إدراج في قائمة الانتظار على المنبثقة - ابدأ هنا في الخلفية + تشغيل في الخلفية المحتوى الإفتراضي حسب البلد تغيير الإتجاه الإنتقال إلى التشغيل في الخلفية @@ -375,7 +373,7 @@ تناسب مع الشاشة توليد تلقائي - إستيراد وتصدير + إستيراد او تصدير إستيراد إستعادة مِن تصدير إلى @@ -419,7 +417,7 @@ المشغل الخارجي لا يدعم هذه الأنواع من الروابط ملف غير صالح / محتوى غير صالح الملف غير موجود أو ليس لديك الإذن الكافي للقراءة أو الكتابة فيه - لا توجد تدفقات متاحة للتنزيل + لا يوجد بث متاح للتنزيل تم حذف عنصر واحد. @@ -438,7 +436,7 @@ مشغل الخلفية المشغل المنبثق - التصدير السابق + نسخة احتياطية تعذر استيراد الاشتراكات لا يمكن تصدير الاشتراكات diff --git a/app/src/main/res/values-b+ast/strings.xml b/app/src/main/res/values-b+ast/strings.xml index a8ee6260c..f9834119d 100644 --- a/app/src/main/res/values-b+ast/strings.xml +++ b/app/src/main/res/values-b+ast/strings.xml @@ -41,7 +41,7 @@ Videu siguiente Amosar vídeos siguientes y asemeyaos URL non sofitada - Llingua por defeutu del conteníu + Llingua por defeutu del conteníu Videu y audiu Aspeutu Otru diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 8a1d35df3..d5c27ce48 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -95,7 +95,7 @@ URL не падтрымліваецца Краіна кантэнту па змаўчанні Сэрвіс - Мова кантэнту па змаўчанні + Мова кантэнту па змаўчанні Плэер Павядзенне Відэа і аўдыё diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 5adc10a1d..11571f4e5 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -74,7 +74,7 @@ Показвай „следващ“ и „подобни“ Показвай съвет „задръж за добавяне“ Непознат URL - Език на съдържанието по подразбиране + Език на съдържанието по подразбиране Плейър Поведение Видео & Аудио @@ -288,7 +288,7 @@ Изтрива историята на възпроизвежданите стриймове Не са намерени видео стриймове Не са намерени аудио стриймове - "Какво:\\nЗаявка:\\nЕзик на съдържанието:\\nУслуга:\\nВреме по GMT:\\nПакет:\\nВерсия:\\nОС версия: " + Какво:\\nЗаявка:\\nЕзик на съдържанието:\\nУслуга:\\nВреме по GMT:\\nПакет:\\nВерсия:\\nОС версия: Миниатюра на видео Потребителски доклад Пренареди чрез плъзгане @@ -461,4 +461,5 @@ Потребители Възстанови + Стъпка diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index f9ec7a07a..3fb51af36 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -57,7 +57,7 @@ পরবর্তী ভিডিও পরবর্তী এবং অনুরূপ ভিডিওগুলি দেখাও URL সমর্থিত নয় - কন্টেন্ট এর জন্য পছন্দসই ভাষা + কন্টেন্ট এর জন্য পছন্দসই ভাষা ভিডিও এবং অডিও পপআপ অ্যাপিয়ারেন্স diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index df5ea0d87..20916ef45 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -177,7 +177,7 @@ Vídeo següent Mostra el vídeo següent i similars País per defecte dels continguts - Llengua per defecte dels continguts + Llengua per defecte dels continguts Emergent S\'està reproduint en segon pla S\'està reproduint en mode emergent diff --git a/app/src/main/res/values-cmn/strings.xml b/app/src/main/res/values-cmn/strings.xml index ca495f134..ccdf23c98 100644 --- a/app/src/main/res/values-cmn/strings.xml +++ b/app/src/main/res/values-cmn/strings.xml @@ -1,22 +1,22 @@ -点按搜索即可开始 - %1$s 意见 - 发布 %1$s - 找不到流播放器。你想安装VLC吗? - 找不到流播放器(您可以安装VLC播放它) +点击搜索按钮即可开始使用 + %1$s 次观看 + 发布于 %1$s + 找不到流媒体播放器。您想安装 VLC 吗? + 找不到流媒体播放器(您可以安装 VLC 来播放) 安装 取消 在浏览器中打开 - 在弹出模式下打开 + 在悬浮窗模式下打开 分享 下载 - 下载流文件. + 下载流媒体文件。 搜索 设置 - 你的意思是: %1$s ? - 与某人分享 + 您是不是要找: %1$s ? + 分享至 选择浏览器 - 回转 + 旋转 使用外部视频播放器 启用此选项时,某些分辨率将不会有音频 使用外部音频播放器 @@ -65,7 +65,7 @@ 黑色 记住弹出窗口的尺寸与位置 记住上一次弹出窗口的位置以及大小 - 清理照片内存 + 已清除图像缓存 最小化弹出播放器 @@ -120,4 +120,11 @@ 从主视频播放器切换到其他应用程序时的操作 - %s 没有 最小化背景播放器 + WebM — 自由视频格式 + 使用快速粗略定位 + 粗略定位功能允许播放器以略低的精确度为代价换取更快的定位速度 + 下载缩略图 + 清除缓存的元数据 + 移除所有缓存的网页数据 + 已清除缓存的元数据 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 5684c4756..d736a9e34 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -40,7 +40,7 @@ Další videa Zobrazovat \'další\' a \'podobná\' videa URL není podporováno - Preferovaný jazyk obsahu + Preferovaný jazyk obsahu Video a zvuk Vzhled Ostatní @@ -372,7 +372,7 @@ otevření ve vyskakovacím okně Náhled playlistu změněn Playlist nelze smazat - Žádné poznámky + Žádné titulky Přizpůsobit Vyplnit @@ -457,8 +457,8 @@ otevření ve vyskakovacím okně Preferovaná \'otevřít\' akce Výchozí chování při otevírání obsahu — %s - Poznámky - Upravuje velikost textu poznámek a styly pozadí. Změny se projeví po restartu aplikace + Titulky + Upravuje velikost textu titulků a styly pozadí. Změny se projeví po restartu aplikace K přehrání tohoto souboru chybí vhodná aplikace @@ -473,4 +473,31 @@ otevření ve vyskakovacím okně Jedna položka smazána. NewPipe je copyleft libre software: Můžete jej používat, sdílet a vylepšovat dle vaší vůle. Redistribuovat a/nebo upravovat lze za podmínek GNU General Public Licence zveřejňované nadací Free Software Foundation, a to buď za podmínek licence verze 3 nebo (dle vaší volby) jakékoli pozdější verze. - + kanály + Playlisty + Stopy + Uživatelé + Zásady ochrany soukromí v NewPipe + Projekt NewPipe bere vaše soukromí velice vážně. Proto aplikace neshromažďuje žádné údaje bez vašeho souhlasu. +\nZásady ochrany soukromí NewPipe podrobně vysvětlují, jaké údaje jsou odesílány a ukládány, když odešlete zprávu o pádu aplikace. + Přečíst zásady ochrany soukromí + Chcete také přenést nastavení? + + Zrychleně vpřed během ticha + Krok + Reset + + Abychom vyhověli Obecnému nařízení o ochraně osobních údajů (GDPR), upozorňujeme vás na zásady ochrany soukromí v NewPipe. Přečtěte si je prosím pozorně. +\nJe potřeba je odsouhlasit, abyste nám mohli odeslat hlášení chyb. + Přijmout + Odmítnout + + Bez omezení + Omezit rozlišení při použití mobilních dat + Minimalizovat při přepínání aplikací + Akce při přepínání aplikací z hlavního přehrávače videa - %s + Žádná + Minimalizovat přehrávač na pozadí + Minimalizovat přehrávač do vyskakovacího okna + + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 37f892b4b..f5bbfb3e0 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -10,7 +10,7 @@ Download Suchen Einstellungen - Meintest du: %1$s ? + Meintest du: %1$s\? Teilen mit Browser auswählen Drehen des Geräts @@ -29,7 +29,7 @@ \'Nächste\' und \'ähnliche\' Videos anzeigen URL wird nicht unterstützt Video & Audio - Bevorzugte Sprache des Inhalts + Bevorzugte Sprache des Inhalts Video-Vorschaubild Video-Vorschaubild Nutzerbild diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 975ad4025..74dfbe229 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -42,7 +42,7 @@ Επόμενο βίντεο Εμφάνιση \"επόμενου\" και \"σχετικών\" βίντεο Δεν υποστηρίζεται η διεύθυνση URL - Προεπιλεγμένη γλώσσα περιεχομένου + Προεπιλεγμένη γλώσσα περιεχομένου Βίντεο & Ήχος Εμφάνιση Άλλα @@ -217,7 +217,7 @@ Μη έγκυρο URL Δε βρέθηκαν ροές βίντεο Δε βρέθηκαν ροές ήχου - Μη έγκυρη τοποθεσία + Μη έγκυρη διεύθυνση Μη έγκυρο αρχείο ή πηγή περιεχομένου Το αρχείο δεν υπάρχει ή δεν έχουμε επαρκή εξουσιοδότηση για να διαβάσουμε ή να γράψουμε σε αυτό Το όνομα αρχείου δεν μπορεί να είναι κενό @@ -301,7 +301,7 @@ Δεν ήταν δυνατή η φόρτωση της άδειας Περί Συνεισφέροντες - Ελέυθερη και ελαφριά εφαρμογή αναπαραγωγής ροών στο Android. + Ανοιχτού κώδικα, ελαφριά εφαρμογή του Android, για την αναπαραγωγή πολυμεσων από το ίντερνετ. Συνεισφέρετε Αν έχετε ιδέες για μετάφραση, αλλαγή σχεδιασμού, εκκαθάριση ή ριζικές αλλαγές κώδικα της εφαρμογής—η βοήθεια σας είναι πάντα ευπρόσδεκτη. Όσο περισσότερη έχουμε, τόσο καλύτεροι γινόμαστε! Δείτε το στο GitHub @@ -405,7 +405,7 @@ Τροποποίηση του μεγέθους και του φόντου των υπότιτλων. Απαιτεί επανεκκίνηση της εφαρμογής Ενεργοποίηση του LeakCanary - Η παρακολούθηση των διαρροών μνήμης μπορεί να κάνει την εφαρμογή να μην αποκρίνεται κατά το heap dumping + Η παρακολούθηση των διαρροών μνήμης μπορεί να προκαλέσει την διακοπή της εφαρμογής @@ -442,7 +442,7 @@ Τέμπο Τόνος Ενέργεια κατά τη μετάβαση σε άλλη εφαρμογή — %s - Σελίδαρ Ροής + Σελίδα Ροής Δημοφιλή Αναφορά σφαλμάτων εκτός κύκλου ζωής Το όνομα χρήστη σας, soundcloud.com/όνομαχρήστη @@ -450,7 +450,7 @@ Αποσύνδεση (μπορεί να προκαλέσει παραμόρφωση) Επιτάχυνση αναπαραγωγής κατά τη διάρκεια σιωπής Βήμα - Μηδενισμός + Επαναφορά Προς συμμόρφωση με τον Ευρωπαϊκό Γενικό Κανονισμό για την Προστασία Δεδομένων (GDPR), σας επιστούμε την προσοχή στην πολιτική προστασίας προσωπικών δεδομένων του NewPipe. Παραλούμε, διαβάστε την προσεκτικά. \nΘα πρέπει να την αποδεχτέιτε προκειμένου να μας αποστείλετε την αναφορά σφάλματος. diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 78faeed8f..9ac81757f 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -28,7 +28,7 @@ Elŝuti Sekva video Ligilo ne subtenita - Preferata enhavlingvo + Preferata enhavlingvo Video kaj sono Apero Alia diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 02a9924a5..3ad9c6874 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -57,7 +57,7 @@ No se pudo descifrar la URL del vídeo No se pudo analizar el sitio web Mostrar vídeos \'siguientes\' y \'similares\' - Idioma del contenido por defecto + Idioma del contenido por defecto Vista previa del vídeo Vista previa del vídeo Me gusta @@ -500,4 +500,55 @@ abrir en modo popup Usuarios Listas de reproducción Pistas + + Finalizadas + En cola + + pausado + en cola + post-procesado + + Encolar + + Acción denegada por el sistema + + Archivo borrado + + + Descarga fallida + Descarga finalizada + %s descargas finalizadas + + + Generar nombre único + Sobrescribir + Ya existe un archivo descargado con este nombre + Hay una descarga en curso con este nombre + + Mostrar como grilla + Mostrar como lista + Limpiar descargas finalizadas + Tienes %s descargas pendientes, ve a Descargas para continuarlas + Detener + Intentos maximos + Cantidad máxima de intentos antes de cancelar la descarga + Pausar al cambiar a datos moviles + No todas las descargas se pueden suspender, en esos casos, se reiniciaran + + + + Mostrar error + Codigo + No se puede crear la carpeta de destino + No se puede crear el archivo + Permiso denegado por el sistema + Fallo la conexión segura + No se puede encontrar el servidor + No se puede conectar con el servidor + El servidor no devolvio datos + El servidor no acepta descargas multi-hilos, intente de nuevo con @string/msg_threads = 1 + Rango solicitado no satisfactorio + No encontrado + Fallo el post-procesado + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index aa3afa7be..aef139cfa 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -92,7 +92,7 @@ URL pole toetatud Sisu vaikimisi riik Teenus - Sisu vaikimisi keel + Sisu vaikimisi keel Pleier Käitumine Heli ja pilt diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 9ea87bd6d..72206774a 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -26,7 +26,7 @@ Hurrengo bideoa Erakutsi \'hurrengo\' eta \'antzeko\' bideoak URLak ez du euskarririk - Edukiaren hizkuntz lehenetsia + Edukiaren hizkuntz lehenetsia Bideoa eta Audioa Erreproduzitu diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 71acd9c69..03f9a33a0 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -43,7 +43,7 @@ ویدیوی بعدی نماش ویدیوهای بعدی و مشابه نشانی اینترنتی پشتیبانی نمی‌شود - زبان محتوای ترجیحی + زبان محتوای ترجیحی ویدیو و صدا ظاهر دیگر @@ -117,4 +117,21 @@ در حافظه رونوشت شد. لطفاً یک شاخهٔ بارگیری موجود را برگزینید. - +هیچ پخش کننده استریمی پیدا نشد (شما می‌توانید برنامه وی‌ال‌سی را برای پخش آن نصب نمایید) + بارگیری پرونده جریان. + هنگامی که این گزینه فعال باشد، برخی از کیفیت‌ها فاقد صدا خواهند بود + اشتراک + مشترک شده + اشتراک کانال لغو شد + ناتوانی در تغییر وضعیت اشتراک + ناتوانی در به‌روزرسانی اشتراک + نمایش اطلاعات + + اصلی + اشتراک‌ها + نشان‌گذاری‌ها + + موارد جدید + + پس زمینه + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index fc9c343b1..ec10422d8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -42,7 +42,7 @@ Aseta sijainti minne äänitiedostot tallennetaan Automaattitoisto - Toistaa automaattisesti videon, kun NewPipe sovellusta kutsutaan toisesta sovelluksesta + Toistaa automaattisesti videon, kun NewPipe avataan toisesta ohjelmasta Oletusresoluutio Ponnahdusikkunan oletusresoluutio Näytä korkeampia resoluutioita @@ -66,18 +66,18 @@ Näytä ehdotuksia hakiessa Hakuhistoria Tallentaa hakutietokannan paikallisesti - Historia + Historia ja Välimuisti Pidä muistissa katsotut videot Jatka toistoa keskeytysten jälkeen (esim. puhelut) Lataus Seuraava video Näytä seuraavia ja samankaltaisia videoita URL ei tueta - Oletus-sisällon kieli + Oletus-sisällon kieli Soitin Käyttäytyminen Video & Ääni - Historia + Historia ja välimuisti Ponnahdusikkuna Ulkoasu Muu @@ -170,7 +170,7 @@ Ei videoita %s video - %s videota + %s videoita Aloita @@ -216,7 +216,7 @@ Tietoja Hyväntekijät Lisenssit - Vapaa kevyt Android YouTube-frontend. + Vapaa ja kevyt nettivideo- ja audiostriimitoistin Androidille. Näytä GitHub:ssa NewPipe:n Lisenssi Olkoon sinulla ideoita; käännöksistä, design muutoksista, koodin siivoamisesta tai raskaista koodimuutoksista—apu on aina tervetullutta. Mitä enemmän saadaan tehtyä, sen paremmaksi sovellus tulee! @@ -232,7 +232,7 @@ Historia pyyhitty Poistettu Haluatko poistaa tämän hakuhistoriasta? -Jatka toistoa +Jatka keskeytyksen jälkeen Info: Mikä:\\nPyyntö:\\nSisällön kieli:\\nPalvelu:\\nGMT Aika:\\nPaketti:\\nVersio:\\nOS versio: © %1$s %2$s %3$s alla @@ -255,7 +255,7 @@ Lisätty ikkunajonoon Toista kaikki - Toisto epäonnistui + Tätä ei voitu toistaa Palautuskelvoton soittimen virhe Palaudutaan soittimen virheestä @@ -265,7 +265,7 @@ Yksityiskohdat Ääniasetukset Pidä pohjassa lisätäksesi jonoon -Näytä vihje soittolistalle +"Näytä vihje soittolistaan lisäämiseen " [Tuntematon] Lisää taustatoistojonoon @@ -274,8 +274,192 @@ Aloita toisto taustalla Aloita toisto ikkunassa Lahjoita - NewPipe kehitetään vapaaehtoisten toimesta, jotka käyttävät vapaa-aikaansa tuottaakseen parhaimman kokemuksen sinulle. Nyt on aika antaa takaisin, jotta kehittäjät voivat tehdä NewPipe:sta vielä paremman nauttiessaan kupin kahvia! + NewPipe kehitetään vapaaehtoisten toimesta, jotka käyttävät vapaa-aikaansa tuottaakseen parhaan mahdollisen käyttökokemuksen sinulle. Nyt on aika antaa takaisin, jotta kehittäjät voivat tehdä NewPipe:sta vielä paremman nauttiessaan kupin kahvia! Anna takaisin Websivu - Käy verkkosivuillamme saadaksesi lisää tietoa ja uusimmat uutiset NewPipe:sta. - + Käy verkkosivuillamme saadaksesi lisää tietoa ja uusimmat uutiset NewPipe:stä. + Soitinta ei löytynyt (voit asentaa VLC:n toistaaksesi) + Lataa toistotiedosto. + Näytä lisätietoja + + Kirjanmerkit + + Lisää soittolistaan + + Käytä nopeampaa epätarkkaa pikakelausta + Epätarkka kelaus mahdollistaa videon kelauksen nopeammin, huonommalla tarkkuudella + Näytä videoiden pikkukuvat + Poista käytöstä lopettaaksesi kaikkien pikkukuvien lataus säästääksesi mobiilidataa ja muistinkäyttöä. Tämän asetuksen muuttaminen poistaa kaikki pikkukuvat välimuistista + Kuvavälimuisti tyhjennetty + Poista tallennettu metatieto + Poista kaikki tallennettu sivutieto + Metatiedot poistettu + Automaattisesti lisää seuraavan youtuben ehdottaman videon soittolistaan, sen viimeisen kappaleen soidessa (Youtuben Autoplay) + Sisällön oletusmaa + Palvelu + "Virheenkorjaus " + Kanavat + Soittolistat + Ääniraidat + Käyttäjät + Aina + Vain kerran + Tiedosto + + Muuta näytön suuntausta + Vaihda taustalle + Vaihda ponnahdusikkunatoistoon + Vaihda normaalitoistoon + + Tuo tietokanta + Vie tietokanta + Kirjoittaa yli tämänhetkisen historian ja tilaukset + Vie historia, tilaukset tai soittolistat + Poista katseluhistoria + Poistaa toistohistorian + Poista koko katseluhistoria. + Katseluhistoria onnistuneesti poistettu. + Poista hakuhistoria + Poistaa historian haetuista videoista + Poista koko hakuhistoria. + Hakuhistoria onnistuneesti poistettu. + Ulkoiset soittimet eivät tue tämänkaltaisia linkkejä + Viallinen osoite + Videojonoja ei löytynyt + Audiojonoja ei löytynyt + Virheellinen kansio + Virheellinen tiedosto/lähde + Tiedosta ei löydy tai sen kirjoittamiseen/lukemiseen ei ole lupaa + Tiedostonimi ei voi olla tyhjä + Hups! Tapahtui virhe: %1$s + Vedä järjestelläksesi + + Luo + Poista yksi + Poista kaikki + Hylkää + Nimeä uudelleen + + 1 poistettu. + + Ohjelmaa tämän toistamiseen ei ole asennettu + + NewPipen tietosuojakäytäntö + NewPipe ottaa yksityisyytesi tosissaan. Siksi se ei kerää sinulta mitään tietoja ilman lupaasi. NewPipen tietosuojakäytännössä selitetään tarkasti mitä tietoja lähetetään tai tallennetaan virheraportin yhteydessä. + Lue tietosuojakäytäntö + Automaattisesti lisää ehdotettu video soittolistaan + Videostriimejä ei löytynyt ladattavaksi + + NewPipe on vapaata softaa. Voit käyttää sitä, katsella sekä muokata sen koodia ja parannella sitä ilmaiseksi mielesi mukaan. Tarkemmin sanottuna, voit jakaa sitä edelleen ja/tai muokata sitä Free Software Foundationin julkaiseman GNU GPL:n, joko versio kolmosen (GPLv3), tai uudemman, ehdoilla. + Haluatko poistaa tämän katseluhistoriasta? + Oletko varma että haluat poistaa kaiken katseluhistoriasta? + Viimeksi toistettu + Eniten toistetut + + Vienti valmis + Tuonti valmis + Virheellinen ZIP-tiedosto + Varoitus: Ei voitu tuoda kaikkia tiedostoja. + Tämä nollaa nykyiset asetuksesi. + Haluatko myös tuoda asetukset? + + Avaa alasvetovalikko + Sulje alasvetovalikko + Tähän tulee jotain jossainvaiheessa ;D + + + Ensisijainen avaustapa + Oletustoiminto avatessa sisältöä — %s + + Videosoitin + Taustasoitin + Ponnahdusikkunasoitin + Kysy aina + + Haetaan infoa… + Ladataan pyydettyä sisältöä + + Luo uusi soittolista + Poista soittolista + Uudelleennimeä soittolista + Nimi + Lisää soittolistaan + Aseta soittolistan kuvakkeeksi + + Tallenna soittolista kirjanmerkkeihin + Poista kirjanmerkki + + Haluatko poistaa tämän soittolistan? + Soittolista luotu + Lisätty soittolistaan + Soittolistan kuvake muutettu + Soittolistaa ei voitu poistaa + + Ei tekstityksiä + + Sovita + Täytä + Suurenna + + Automaattisesti luotu + + Tekstitykset + Muokkaa tekstitysten kokoa ja taustaa. Asetusten päivittämiseksi sinun tulee käynnistää ohjelma uudelleen + + Laita päälle LeakCanary-muistivuodonpaljastin (virheenkorjausta varten) + Muistivuotojen valvonta voi aiheuttaa ohjelman hidastumisen virhetilanteissa + + Raportoi yhteensopimattomuusvirheitä jotka aiheutuvat vanhoista ohjelmista + Pakota raportointi kuljetuskelvottomille Rx-poikkeuksille jotka ovat muistisirpaleiden tai aktiviteettielämänkaaren ulkopuolella poiston jälkeen + + Tuo/Vie + Tuo + Tuo kohteesta + Vie kohteeseen + + Tuodaan… + Viedään… + + Tuo tiedosto + Edellinen vienti + + Tilauksia ei voitu tuoda + Tilauksia ei voitu viedä + + Tuo youtube-tilaukset lataamalla ensin tilauslistatiedostosi: +\n +\n1. Mene osoitteeseen: %1$s +\n2. Kirjaudu sisään kun niin vaaditaan +\n3. Latauksen pitäisi alkaa (se on se tiedosto) + Tuo SoundCloud-profiili kirjoittamalla joko osoite tai ID:si: +\n +\n1. Laita päälle työpöytämoodi selaimessasi (tai käytä tietokonetta, tämä sivu ei toimi mobiilisivuna) +\n2. Mene tähän osoitteeseen: %1$s +\n3. Kirjaudu sisään kysyttäessä. +\n4. Kopioi se nettiosoite johon sinut ohjattiin. + sinun käyttäjänimesi, soundcloud.com/<sinun käyttäjänimesi> + + Ota huomioon että tämä operaatio voi käyttää paljon dataa. +\nHaluatko jatkaa? + + Toiston nopeuden säätimet + Musiikkitempo + Sävelkorkeus + Irroita tempo ja nopeus toisistaan (saattaa aiheuttaa säröä äänessä) + Kelaa eteenpäin hiljaisissa kohdissa + Askel + Nollaa + + Jotta olisimme kelpoisia EU:n uuden yleisen tietosuoja-asetuksen (GDPR) kannalta, meidän pitää muistuttaa sinua lukemaan NewPipen tietosuojakäytäntö. Lue se oikeasti. Sinun pitää myös hyväksyä se jotta voit lähettää virheraportin. + Hyväksy + Hylkää + + Ei rajaa + Rajoita resoluutiota kun mobiilidata on käytössä + Pienennä vaihtaessa ohjelmaa + Toiminto kun vaihdetaan toiseen ohjelmaan päävideosoittimesta — %s + Ei koskaan + Pienennä taustasoittimeksi + Muuta ponnahdusikkunaksi + + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ef071c316..07deb1659 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -35,7 +35,7 @@ Miniature d’aperçu vidéo Je n’aime pas J’aime - Langue du contenu par défaut + Langue du contenu par défaut Miniature de l’avatar de l’utilisateur Utiliser un lecteur vidéo externe Utiliser un lecteur audio externe @@ -431,14 +431,12 @@ \n1. Suivez ce lien : %1$s \n2. Connectez-vous à votre compte lorsque ce sera demandé \n3. Un téléchargement va démarrer (ce sera celui du fichier nécessaire à l\'importation des abonnements)" - Pour importer vos abonnements SoundCloud, vous devez connaitre l\'URL de votre profil ou votre identifiant (id). Si vous le savez, tapez-le ci-dessous. + Importez un profil SoundCloud avec l\'URL de votre profil ou votre identifiant (id) : \n -\nSi vous ne le connaissez pas, veuillez suivre les étapes suivantes : -\n -\n1. Activer le \"mode bureau\" dans votre navigateur (le site n\'est pas accesible en mode mobile) +\n1. Activer le \"mode bureau\" dans votre navigateur (le site n\'est pas accessible en mode mobile) \n2. Suivez cette URL : %1$s \n3. Connectez-vous à votre compte -\n4. Copier l\'URL vers lequel vous venez d\'être redirigé (qui est l\'URL de votre profil) +\n4. Copier l\'URL vers lequel vous venez d\'être redirigé (qui est l\'URL de votre profil). votreID, soundcloud.com/votreid Cette opération peut consommer beaucoup de données mobiles. @@ -495,7 +493,7 @@ Minimiser lors du changement d\'application Action lors du changement d\'applications depuis le lecteur vidéo —%s Aucune - Lecture en arrière plan - Mettre en lecteur popup + Minimiser pour lire en arrière plan + Minimiser vers lecteur PopUp diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml new file mode 100644 index 000000000..f137a092e --- /dev/null +++ b/app/src/main/res/values-gl/strings.xml @@ -0,0 +1,464 @@ + +Toque em «Procurar» para comezar + %1$s visualizacións + Publicado o día %1$s + Non foi encontrado ningún reprodutor. Desexa instalar o VLC? + Non foi encontrado ningún reprodutor (pode instalar o VLC para o reproducir) + Instalar + Cancelar + Abrir no navegador + Abrir no modo popup + Compartillar + Descarregar + Descarregar o ficheiro de emisión. + Procurar + Opcións + Non querería dicir «%1$s»? + Compartillar con + Escoller un navegador + rotación + Usar un reprodutor de vídeo externo + Podería NON haber audio con algunhas resolucións se esta opción estiver activa + Usar un reprodutor externo de audio + Modo popup do NewPipe + Subscribir + Subscrito + Xa non está subscrito ao canal + Non foi posíbel modificar a subscrición + Non foi posíbel actualizar a subscrición + Mostrar información + + Principal + Subscricións + Favoritos + + Novidades + + Segundo plano + Modo popup + Engadir a + + Ruta de descarga de vídeos + Ruta onde gardar os vídeos descarregados + Introduza a ruta de descarga de vídeos + + Ruta de descarga de audio + Ruta onde gardar o audio descarregado + Introduza a ruta de descarga dos ficheiros de audio + + Reproducir automaticamente + Reproducir o vídeo pedido cando o NewPipe sexa invocado por outro aplicativo + Resolución predeterminada + Resolución predeterminada do popup + Mostrar resolucións máis altas + Moitos dispositivos non permiten reproducir vídeos en 2K/4K + Reproducir co Kodi + O aplicativo Kore non foi encontrado. Desexa instalalo? + Mostrar a opción «Reproducir co Kodi» + Mostrar unha opción para reproducir o vídeo co Kodi Media Center + Audio + Formato de audio predeterminado + Formato de vídeo predeterminado + WebM — formato libre + M4A — mellor calidade + Tema + Claro + Escuro + Negro + Lembrar o tamaño e a posición do «popup» + Lembrar o tamaño e a posición anteriores do «popup» + Usar un salto inexacto mais inexacto + O salto inexacto permite saltar a posicións máis rápido, mais con menos precisión + Carregar miniaturas + Desactíveo para evitar a carga de miniaturas e poupar datos e memoria. Modificar esta opción limpa a caché de imaxes da memoria e do disco + A caché de imaxes foi limpada + Os metadatos da caché foron eliminados + Eliminar todos os datos de páxinas en caché + Os metadatos da caché foron eliminados + Colocar a seguinte emisión na cola automaticamente + Engadir automaticamente unha emisión ao reproducir a última emisión nunha cola sen repetición + Controis de xesto do reprodutor + Usar xestos para controlar o brillo e volume do reprodutor + Suxestións de procura + Mostrar suxestións ao procurar + Historial de procura + Gardar os termos de pesquisa localmente + Historial e caché + Gardar historial de vídeos vistos + Retormar o vídeo ao enfocar + Continuar a reprodución após interrupcións (como chamadas) + Descarregar + Vídeo seguinte + Mostrar vídeos «seguintes» e «semellantes» + Mostrar a suxestión «Manteña presionado para engadir á cola» + Mostrar unha suxestión ao premer o botón de segundo plano ou o de popup na páxina de detalles do vídeo + Este URL non está soportado + País predeterminado para o contido + Servizo + Idioma predeterminado do contido + Reprodutor + Comportamento + Vídeo e audio + Historial e caché + Popup + Aparencia + Outros + Depuración + Reproducindo en segundo plano + Reproducindo en modo «popup» + Na cola do reprodutor en segundo plano + Na cola do reprodutor popup + Reproducir + Contido + Mostrar contido con restrición de idade + Vídeo con restrición de idade. Pode permitir a reprodución deste material nas Opcións. + en directo + Descargas + Descargas + Relatorio de erro + Todo + Canal + Canais + Lista de reprodución + Listas de reprodución + Pistas + Usuarios + Si + Máis tarde + Desactivado + Filtro + Actualizar + Limpar + Redimensionando + Mellor resolución + Desfacer + Reproducilos todos + Sempre + Só unha vez + Ficheiro + + Notificación do NewPipe + Notificacións para o NewPipe e os reprodutores «popup» + + [Descoñecido] + + Mudar a orientación + Mudar para o segundo plano + Mudar para o «popup» + Mudar para principal + + Importar base de datos + Exportar base de datos + Isto vai sobreescribir o seu historial e as súas subscricións actuais + Exportar historial, subscricións e listas de reprodución + Limpar historial de reproducións + Elimina o historial de emisións reproducidas + Elimina todo o historial de reproducións. + O historial de reproducións foi eliminado. + Limpar o historial de procura + Elimina o historial de termos procurados + Elimina todo o historial de procura. + O historial de procuras foi eliminado. + Erro + Erro de rede + Non foi posíbel carregar todas as miniaturas + Non foi posíbel descifrar a asinatura do vídeo + Non foi posíbel procesar o sitio web + Non foi posíbel procesar o sitio web por completo + O contido non está dispoñíbel +\n + Bloqueado pola GEMA + Non foi posíbel configurar o menú de descargas + Isto é unha emisión en directo, polo que aínda non está soportado. + Non foi posíbel obter unha emisión + Non foi posíbel carregar a imaxe + O aplicativo pechouse + Non foi posíbel reproducir este vídeo + Ocorreu un erro irrecuperábel co reprodutor + Recuperándose dun erro do reprodutor + Os reprodutores externos non soportan estes tipos de ligazóns + URL inválido + Non foi encontrada ningunha emisión de vídeo + Non foi encontrada ningunha emisión de audio + Directorio inválido + A fonte do ficheiro ou contido é inválida + O ficheiro non existe ou non ten permisos suficientes para o ler ou escribir + O nome do ficheiro non pode estar vacío + Ocorreu un erro: %1$s + Non hai emisións para descargar + + Deculpe, isto non debería ter acontecido. + Informar do erro por correo electrónico + Desculpe, ocorreron algúns erros. + Relatorio + Información: + Que ocorreu: + Problema:\\nPetición:\\nIdioma do contido:\\nServizo:\\nHora GMT:\\nPaquete:\\nVersión:\\nVersión do SO: + O teu comentario (en inglés): + Detalles: + + + Miniatura do vídeo + Miniatura do vídeo + Miniatura do avatar do autor + Gosto + Non gosto + Usar o Tor + (Experimental) Redirixir o tráfico polo Tor para aumentar a privacidade (as emisións aínda non están soportadas). + Informar dun erro + Relatorio do usuario + Non hai resultados + Nada que ver + Arrastre para reordenar + + Non foi posíbel crear o directorio «%1$s» + O directorio de descargas «%1$s» foi creado + + Vídeo + Audio + Tentar de novo + A permisión de acceso ao almacenamento foi denegada + Usar o reprodutor antigo + Versión interna anticuada do reprodutor Mediaframework + + K + M + MM + + Ningún subscrito + + %s subscrito + %s subscritos + + + Ningunha visualización + + %s visualización + %s visualizacións + + + Ningún vídeo + + %s vídeo + %s vídeos + + + Comezar + Pausar + Reproducir + Crear + Eliminar + Eliminar un + Eliminalos todos + Suma de comprobación + Descartar + Renomear + + Nova misión + OK + + Nome do ficheiro + Fios + Erro + O servidor non está soportado + O ficheiro xa existe + O URL está mal formado ou a Internet non está dispoñíbel + Descarga do NewPipe + Toque para ver detalles + Por favor, agarde… + Copiado para o portarretallos + Por favor, seleccione o directorio para descargas + Esta permisión é necesaria +\npara abrir o vídeo no modo «popup» + 1 elemento foi eliminado. + + reCAPTCHA + Desafío reCAPTCHA + Desafío reCAPTCHA solicitado + + Descarregar + Caracteres permitidos nos nomes de ficheiros + Os caracteres inválidos serán substituídos por este valor + Carácter de substitución + + Letras e díxitos + A maioría dos caracteres especiais + + Non hai ningún aplicativo instalado para reproducir este ficheiro + + Sobre o NewPipe + Opcións + Sobre + Licenzas de terceiros + © %1$s de %2$s, so %3$s + Non foi posíbel carregar a licenza + Abrir o sitio web + Sobre + Colaboradores + Licenzas + Reprodutor lixeiro e libre para Android. + Colaborar + Se ten ideas de tradución, mudanzas ao deseño, limpeza de código ou mudanzas serias deste—a axuda sempre é benvida. Canto máis fixermos, tanto máis vai mellorar! + Ver no GitHub + Doar + O NewPipe é desenvolvido por voluntarios que empregan o seu tempo libre para lle ofrecer a mellor experiencia. Pode retribuír e así axudar os desenvolvedores a tornaren o NewPipe aínda mellor en canto desfrutan dunha cunca de café. + Retribuír + Sitio web + Visite o sitio web do NewPipe para saber máis e ver noticias sobre o proxecto. + Política de privacidade do NewPipe + O proxecto NewPipe dá moita importancia á súa privacidade. Por tanto, non recolle ningún dato sen o seu consentimento. +\nA política de privacidade do NewPipe explica con máis detalle que datos son enviados e gardados cando envía un relatorio de erros. + Ler a política de privacidade + Licenza do NewPipe + O NewPipe é software copyleft libre: pode usalo, estudalo, compartillalo e melloralo como quixer. En concreto, pode redistribuílo e/ou modificalo so os termos da Licenza Pública Xeral GNU, publicada pola Free Software Foundation, quer a versión 3 da Licenza, ou calquera outra versión posterior da súa escolla. + Ler a licenza + + + Historial + Procurado + Visto + O historial está desactivado + Historial + O historial está vacío + O historial foi limpado + O elemento foi eliminado + Desexa eliminar este elemento do historial de procura? + Desexa eliminar este elemento do historial de visualizacións? + Ten a certeza de querer eliminar todos os elementos do historial? + Última reprodución + Máis reproducido + + Contido da páxina principal + Páxina en branco + Páxina do «kiosk» + Páxina de subscricións + Páxina da fonte + Páxina do canal + Seleccione un canal + Non subscribiu ningún canal + Seleccione un «kiosk» + Exportación completa + Importación completada + Ficheiro ZIP inválido + Aviso: non todos os ficheiros foron importados. + Isto vai reescribir a súa configuración actual. + Desexa importar tamén as opcións? + + Kiosk + Tendencias + Top 50 + Novo e popular + Reprodutor en segundo plano + Reprodutor «popup» + Eliminar + Detalles + Opcións de audio + Manteña para colocar na cola + Colocar na cola de segundo plano + Colocar na cola de popup + Comezar a reprodución aquí + Comezar aquí en segundo plano + Comezar aquí en popup + + Abrir o menú + Fechar o menú + Algo vai xurdir aquí en breve ;D + + + Acción «abrir» preferida + Acción predeterminada ao abrir o contido — %s + + Reprodutor de vídeo + Reprodutor en segundo plano + Reprodutor en popup + Preguntar sempre + + Obtendo información… + Carregando o contido solicitado + + Crear unha nova lista de reprodución + Eliminar a lista de reprodución + Renomear a lista de reprodución + Nome + Engadir á lista de reprodución + Estabelecer como miniatura da lista de reprodución + + Gardar a lista de reprodución nos marcadores + Eliminar o marcador + + Desexa eliminar esta lista de reprodución? + A lista de reprodución foi creada + O vídeo foi engadido á lista de reprodución + A miniatura da lista de reprodución foi modificada + Non foi posíbel eliminar a lista de reprodución + + Sen lenda + + Axustar + Encher + Zoom + + Xerado automaticamente + + Lenda + Modificar a escala de texto da lenda e os estilos de segundo plano do reprodutor. Para ter efecto, é preciso reiniciar o aplicativo + + Activar LeakCanary + A monitorización de fugas de memoria pode facer que o aplicativo deixe de responder cando hai vertedura da pila + + Informar de erros de fóra do ciclo de vida + Forzar a comunicación de excepcións Rx non entregábeis fóra do ciclo de vida do fragmento ou actividade após o descarte + + Importar/Exportar + Importar + Importar de + Exportar a + + Importando… + Exportando… + + Importar un ficheiro + Exportación anterior + + Non foi posíbel importar as subscricións + Non foi posíbel exportar as subscricións + + Pode importar as súas subscricións de YouTube descarregando o ficheiro de exportacións: +\n +\n1. Acceda ao URL %1$s +\n2. Inicie a sesión cando lle for solicitado +\n3. Ha comezar unha descarga (a do ficheiro de exportación) + Pode importar un perfil do SoundCloud escribindo o URL ou o seu ID: +\n +\n1. Active o «modo desktop» nun navegador da Internet (o sitio non está dispoñíbel para dispositivos móbiles) +\n2. Acceda ao URL %1$s +\n3. Inicie a sesión cando lle for solicitado +\n4. Copie o URL de perfil a que foi redirixido. + oseuID, soundcloud.com/oseuid + + Teña en conta que esta operación pode consumir moitos recursos de rede. +\n +\nDesexa continuar? + + Controis de velocidade da reprodución + Tempo + Ton + Desvincular (pode causar distorsión) + Avanzar rápido durante os momentos de silencio + Paso + Reiniciar + + Para cumprirmos co Regulamento Xeral Europeo de Protección de Datos (GDPR), chamamos a súa atención sobre a nova política de privacidade do NewPipe. Por favor, léao con coidado. +\nDebe aceptalo para nos enviar un relatorio de erro. + Aceptar + Recusar + + Sen límite + Limitar a resolución ao usar datos móbiles + Minimizar ao mudar de aplicativo + Acción ao mudar de aplicativo desde o reprodutor orixinal — %s + Ningunha + Minimizar ao reprodutor en segundo plano + Minimizar o reprodutor popup + + diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 21e944ea8..3ba9bbd09 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -59,7 +59,7 @@ הסרטון הבא הראה את הסרטונים הבאים וסרטונים דומים כתובת URL לא נתמכת - שפת התוכן המועדפת + שפת התוכן המועדפת סרטים ושמע חלון צץ תצוגה diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 22e90ac25..bca7a62cc 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -104,7 +104,7 @@ \"संलग्न करने के लिए पकड़ें रहे\" दिखाए जब बैकग्राउंड और पॉपअप बटन विडियो के विवरण पन्ने में दबाई जाए तो tip को दिखाए ये वाला URL इसमें नहीं चलेगा - डिफ़ॉल्ट विषय की भाषा + डिफ़ॉल्ट विषय की भाषा प्लेयर चाल चलन पॉपअप @@ -247,7 +247,7 @@ %2$s के द्वारा © %1$s जो %3$s के अधीन आते है लाइसेंस load नहीं हो रहा सहयोगकर्ता - एंड्राइड के लिए हल्का और मुफ्त स्ट्रीमिंग। + एंड्राइड के लिए हल्का और मुफ्त स्ट्रीमिंग एप्लिकेशन| अगर आपके पास कोई सुझाव हो जैसे -अनुवाद , डिजाईन में बदलाव ,code को साफ़ रखना , या फिर code में जायदा बदलाव लाना हो तो - साहयता के लिए आपका स्वागत है . जितना ज्यादा होगा उतना बेहतर होगा ! क्या आप इसको खोज इतिहास के मिटाना चाहते है ? @@ -295,7 +295,7 @@ डेटाबेस आयात करें डेटाबेस निर्यात करें आपके वर्तमान इतिहास और सब्सक्रिप्शन को ओवरराइड करेगा - इतिहास, सब्सक्रिप्शन और प्लेलिस्ट निर्यात करें । + इतिहास, सब्सक्रिप्शन और प्लेलिस्ट निर्यात करें एक्सटर्नल प्लेयर इन प्रकार के लिंक सपोर्ट नहीं करता अमान्य URL कोई वीडियो स्ट्रीम नहीं मिला @@ -383,7 +383,7 @@ सभी कैश किए गए वेबपृष्ठ डेटा हटाएं मेटाडाटा कैश मिटा दिया गया अगली स्ट्रीम को स्वचालित रूप से जोड़ें - गैर-दोहराने वाली कतार में अंतिम स्ट्रीम चलाते समय संबंधित स्ट्रीम को स्वतः संलग्न करें। + गैर-दोहराने वाली कतार में अंतिम स्ट्रीम चलाते समय संबंधित स्ट्रीम को स्वतः संलग्न करें फाइल चेनल्स @@ -416,7 +416,7 @@ क्या आप सेटिंग्स भी आयात करना चाहते है? पसंदीदा \'खोलने पर\' करवाई - सामग्री खोलते समय डिफ़ॉल्ट कारवाही + सामग्री खोलते समय डिफ़ॉल्ट कारवाही — %s केप्सन प्लेयर केप्शन के शब्दों का परिमाण और पृष्ठभूमि शैलियो को बदले। लागू करने के लिए ऐप को पुनः प्रारम्भ करना जरूरी है। diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 8e73cdf4f..a6a532975 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -42,7 +42,7 @@ Unesi put za preuzimanje zvučne datoteke Auto. reprod. kada je NewPipe pozvan iz druge apl. - Automatski reproduciraj videozapis kad je NewPipe pozvan iz druge aplikacije + Reproducira videozapis kad je NewPipe pozvan iz druge aplikacije Zadana razlučivost Zadana razlučivost skočnog prozora Prikaži veće razlučivosti @@ -77,7 +77,7 @@ Sljedeći videozapis Prikaži sljedeće i slične videozapise URL nije podržan - Zadani jezik sadržaja + Zadani jezik sadržaja Video i zvuk Skočni prozor Izgled @@ -116,7 +116,7 @@ Nije moguće dobaviti stream Nije moguće učitati sliku Aplikacija/UI se srušio - Oprostitee, ovo se nije trebalo dogoditi. + Oprostite, ovo se nije trebalo dogoditi. Prijavi pogrešku putem e-maila Oprostite, neke greške su se dogodile. PRIJAVI @@ -284,7 +284,7 @@ Web stranica Ovdje započni reprodukciju Ovdje započni repr. u pozadini - Dadaj na red čekanja u pozadini + Dodaj na red čekanja u pozadini Dodaj na red u skočnom prozoru Započni ovdje u Skočnom prozoru @@ -333,4 +333,109 @@ Monitoring curenja memorije može uzrokovati greške u radu aplikacije prilikom odlaganje gomile Izvijestite o pogreškama izvan životnog ciklusa + Prikaži informacije + + Oznake + + Dodaj u + + Učitaj slike + Slikovna predmemorija obrisana + Obriši predmemorijsku metupodataka + Usluga + Kanali + Playliste + Pjesme + Korisnici + Uvijek + Samo jednom + Datoteka + + Prijeđi na pozadinu + Prijeđi na skočni prozor + Prijeđi na glavni + + Uvoz baze podataka + Izvoz baze podataka + Poništava vašu trenutnu povijest i pretplate + Izvoz povijesti, pretplata i playlisti + Očisti povijest gledanja + Briše povijest reproduciranih streamova + Obriši cijelu povijest gledanja. + Povijest gledanja izbrisana. + Obriši povijest pretraživanja + Obriši cijelu povijest pretraživanja. + Povijest pretraživanja obrisana. + Neispravan URL + Nevažeći direktorij + Naziv datoteke ne može biti prazan + Dogodila se greška: %1$s + Povucite za promjenu redoslijeda + + Stvori + Izbriši jedan + Izbriši sve + Odbaci + Preimenuj + + 1 stavka izbrisana. + + Nijedna aplikacija nije instalirana za reprodukciju te datoteke + + Vrati + Posjetite web stranicu NewPipe za više informacija i vijesti. + NewPipeova pravila o privatnosti + Pročitajte pravila o privatnosti + Želite li izbrisati ovu stavku iz povijesti gledanja? + Jeste li sigurni da želite izbrisati sve stavke iz povijesti? + Zadnje svirano + Najviše svirano + + Izvoz završen + Uvoz završen + Nema važeće ZIP datoteke + Upozorenje: Nije moguće uvesti sve datoteke. + Ovo će poništiti vaše trenutne postavke. + Želite li također uvesti postavke? + + Uvoz/Izvoz + Uvoz + Uvoz iz + Izvoz u + + Uvoz… + Izvoz… + + Uvoz datoteke + Prethodni izvozi + + Nije moguće uvesti pretplatnike + Nije moguće izvesti pretplatnike + + Uvezite YouTube pretplatnike preuzimanjem izvozne datoteke: +\n +\n1. Idite na ovaj URL: %1%s +\n2. Ulogirajte se +\n3. Preuzimanje bi trebalo početi (to je izvozna datoteka) + vašID, soundcloud.com/vašID + + Uzmite u obzir da ova operacija može uzrokovat veliku potrošnju prometa. +\n +\nŽelite li nastaviti? + + Kontrole brzine reprodukcije + Premotaj naprijed tijekom šutnje + Korak + Resetiraj + + Prihvati + Odbij + + Bez ograničenja + Ograniči rezoluciju tijekom korištenja mobilnih podataka + Nijedan + Reproduktor za stream nije pronađen (možete instalirati VLC za reprodukciju) + Preuzmite datoteku za stream. + Koristi brzo netočno premotavanje + Netočno premotavanje omogućava reproduktoru da premota na mjesto brže uz manju preciznost diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index f4687149a..2b811d53b 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -2,7 +2,7 @@ %1$s megtekintés Közzétéve: %1$s - Nem található megfelelő lejátszó. Feltelepíti a VLC lejátszót? + Nem található megfelelő lejátszó. Telepíted a VLC lejátszót? Telepítés Mégse Megnyitás böngészőben @@ -15,22 +15,24 @@ Válasszon böngészőt forgatás Videófájlok letöltési helye - Útvonal a letöltött videók tárolásához. + Útvonal a letöltött videók tárolásához Adja meg a videófájlok letöltési helyét Alapértelmezett felbontás Lejátszás Kodi-val - A Kore alkalmazás nem található. Feltelepíti a Kore lejátszót? + A Kore alkalmazás nem található. Telepíted a Kore lejátszót? \"Lejátszás Kodi-val\" opció mutatása - Mutat egy opciót a videók Kodi médiaközponttal való lejátszására. + Opció mutatása a videók Kodi médiaközponttal való lejátszására Hang Alapértelmezett hang formátum + WebM — szabad formátum + M4A — jobb minőség Letöltés Következő videó A webcím nem támogatott Külső videólejátszó használata Külső hanglejátszó használata - Adja meg a hangfájlok letöltési helyét. + Add meg a hangfájlok letöltési helyét Tor használata (Kísérleti) Adatforgalom Tor-on keresztüli kényszerítése a biztonság fokozása érdekében (a videó stream-elés még nem támogatott). @@ -41,7 +43,7 @@ Lejátszás háttérben Videó és audio Megjelenés - Tartalom preferált nyelve + Tartalom preferált nyelve További beállítások Következő hasonló videók mutatása Sötét @@ -60,12 +62,12 @@ Tartalom Korhatáros tartalom mutatása Hiba - A tartalom nem elérhető. - GEMA által blokkolt. - Ez egy élö közvetités. Még nem támogatva. - Automatikus lejátszás külső indítás esetén - Videók automatikus lejátszása a NewPipe külső indításakor. - A video korhatáros. A megtekintéséhez engedélyezze a korhatáros videókat. + A tartalom nem elérhető + GEMA által blokkolt + Ez egy élő közvetítés, amely még nem támogatott. + Automatikus lejátszás + Videók automatikus lejátszása, ha a NewPipe egy másik alkalmazásból lett indítva + Korhatáros videó. A megtekintéséhez engedélyezze a korhatáros videókat a Beállításokban. élő JELENTÉS @@ -76,11 +78,11 @@ Sajnos hiba történt. - Ennek nem kellett volna megtörténnie. - Hibajelentés emailben - A stream nem elérhető. - Nem sikerült a letöltés menü beállítása. - Nem sikerült a video url aláírás azonosítása. + Elnézést, ennek nem kellett volna megtörténnie. + Hibajelentés e-mailben + A stream nem elérhető + Nem sikerült a letöltés menü beállítása + Nem sikerült a videó URL aláírás azonosítása Háttértárhoz való hozzáférés megtagadva Újra Hibajelentés @@ -90,21 +92,21 @@ Audio A kezdéshez koppints a keresésre Nem sikerült az összes előnézeti kép betöltése - Nem sikerült a weblap betöltése. - Nem sikerült a weblap teljes betöltése. + Nem sikerült a weblap betöltése + Nem sikerült a weblap teljes betöltése Letöltések Letöltések Hibabejelentés Indít Szünet - Megtekintés + Lejátszás Törlés Ellenőrző összeg - Ok + Rendben - Filenév + Fájlnév Threadek Hiba A file már létezik @@ -146,12 +148,12 @@ Keresési javaslatok Mutasson javaslatokat keresés közben Keresési előzmények - Előzmények + Előzmények és gyorsítótár Megnézett videók nyomon követése "Lejátszás folytatása félbeszakítás után (pl.: telefonhívás) " Lejátszó Működés - Előzmények + Előzmények és gyorsítótár Felugró ablak Hibaelhárítás Lejátszás felugró ablakban @@ -180,7 +182,7 @@ Adatbázis importálása Adatbázis exportálása Felül fogja írni a jelenlegi előzményeket és feliratkozásokat - Előzmények,Feliratkozások és lejátszási listák exportálása. + Előzmények, feliratkozások és lejátszási listák exportálása Kép betöltése sikertelen Alkalmazás/Kezelő felület összeomlott Videó lejátszása sikertelen @@ -192,4 +194,242 @@ Mi:\\nKérés:\\nTartalom nyelve:\\nSzolgáltatás:\\nGMT Idő:\\nCsomag:\\nVerzió:\\nOperációs Rendszer verzió: Nincs találat Régi lejátszó használata - + Adatfolyam fájl letöltése. + Hozzáadás + + Gyorsabb, de pontatlan tekerés használata + A pontatlan tekerés lehetővé teszi, hogy gyorsabban ugorjon a pozíciókra, de kevesebb pontossággal + Bélyegképek betöltése + Kapcsold ki, hogy a bélyegképek ne legyenek betöltve és mentve, így csökkentve az adat és memória használatot. Ennek az értéknek a megváltoztatása törli a memóriában és a meghajtón lévő bélyegkép gyorsítótárat + A bélyegkép gyorsítótár törölve + Gyorsítótárazott metaadat törlése + Minden gyorsítótárazott weboldal adat törlése + A metaadat gyorsítótár törölve lett + Következő videó automatikus lejátszása + Keresési előzmények tárolása az eszközön + Szolgáltatás + Csatornák + Lejátszási listák + Zeneszámok + Felhasználók + Fájl + + Folytatás a háttérben + Folytatás felugró ablakban + Megtekintési előzmények törlése + Eltávolítja a megtekintési előzményeket + Összes megtekintési előzmény törlése. + Megtekintési előzmények törölve. + Keresési előzmények törlése + Eltávolítja a kereséshez használt kifejezéseket az előzményekből + Összes keresési előzmény törlése. + Keresési előzmények törölve. + Lejátszási hiba történt + Lejátszási hiba helyreállítása + Érvénytelen könyvtár + Érvénytelen fájl vagy tartalom forrás + A fájl nem létezik vagy jogosultsági hiba lépett fel + A fájl neve nem lehet üres + Hiba történt: %1$s + Nincs letölthető adatforrás + + Itt nincs semmi + Húzza az átrendezéshez + + Régi beépített Mediaframework lejátszó + + e + M + Mrd + + Nincs feliratkozó + + %s feliratkozó + %s feliratkozó + + + Nincs megtekintés + + %s megtekintés + %s megtekintés + + + Nincs videó + + %s videó + %s videó + + + Létrehozás + Egy törlése + Összes törlése + Átnevezés + + Ez az engedély szükséges a felugró ablakban történő megnyitáshoz + 1 elem törölve. + + reCAPTCHA + reCAPTCHA rejtvény + reCAPTCHA rejtvény igényelve + + Letöltés + Fájlnevekben engedélyezett karakterek + Érvénytelen karakterek ezzel az értékkel kerülnek helyettesítésre + Csere karakter + + Betűk és számok + Legtöbb speciális karakter + + Nincs a fájl lejátszásához szükséges alkalmazás telepítve + + A NewPipe alkalmazásról + Beállítások + Az alkalmazásról + Engedély betöltése sikertelen + Honlap megnyitása + Az alkalmazásról + Közreműködők + Engedélyek + Ingyenes, egyszerű streamelés Androidon. + Közreműködés + Legyen ötleted a fordítással, a megjelenéssel, a forrás kód tisztításával vagy komolyabb átszervezésével kapcsolatban, bármilyen segítséget szívesen fogadunk. Járulj hozzá az alkalmazás fejlesztéséhez! + Megtekintés GitHubon + Adományozás + A NewPipe alkalmazást önkéntesek fejlesztik szabadidejükben, hogy a lehető legjobb élményt hozzák el neked. Járulj hozzá a fejlesztők kávéjához, hogy tovább dolgozhassanak az alkalmazáson! + Hozzájárulás + Honlap + Látogasd meg a NewPipe honlapját további információkért és hírekért! + A NewPipe adatvédelmi irányelvei + A NewPipe projekt komolyan veszi az adataid védelmét. Az alkalmazás nem gyűjt semmilyen adatot a beleegyezésed nélkül. +\nA NewPipe adatvédelmi irányelve részletesen elmagyarázza, mely adatok kerülnek elküldésre és tárolásra az alkalmazás összeomlásának jelentésekor. + Az adatvédelmi irányelvek elolvasása + A NewPipe engedélye + Engedély elolvasása + + + Előzmények + Keresési előzmények + Megtekintési előzmények + Előzmények kikapcsolva + Előzmények + Nincsenek előzmények + Előzmények törölve + Elem törölve + Törölni kívánja ezt az elemet a keresési előzmények közül\? + Törölni kívánja ezt az elemet a megtekintési előzmények közül\? + Biztosan törölni kíván minden elemet az előzmények közül\? + Utoljára lejátszott + Legtöbbet lejátszott + + Főoldal tartalma + Üres oldal + Újságárus oldal + Feliratkozási oldal + Feed oldal + Csatorna oldal + Csatorna választása + Még nincs csatorna feliratkozás + Újságárus választása + Sikeres export + Sikeres import + Nem érvényes ZIP fájl + Figyelmeztetés: nem sikerült az összes fájl importálása. + Ez felül fogja írni a jelenlegi beállításokat. + A beállításokat is importálni kívánja\? + + Újságárus + Felkapott + Top 50 + Új és friss + Felugró ablak lejátszó + Eltávolítás + Részletek + Hang beállítások + Itt hamarosan megjelenik valami :) + + + Alapértelmezett tevékenység „%s” típusú tartalom megnyitásakor + + Videólejátszó + Lejátszás háttérben + Felugró ablakos lejátszás + Mindig kérdezz rá + + Információk gyűjtése… + Kért tartalom betöltése + + Új lejátszási lista létrehozása + Lejátszási lista törlése + Lejátszási lista átnevezése + Név + Lejátszási listához adás + Beállítás lejátszási lista előképeként + + Könyvjelző törlése + + Kívánja törölni ezt a lejátszási listát\? + Lejátszási listsa létrehozva + Lejátszási listához hozzáadva + Lejátszási lista előképe megváltozott + Nem sikerült a lejátszási lista törlése + + Felirat kikapcsolva + + Illeszkedés + Kitöltés + Közelítés + + Automatikusan létrehozott + + Feliratok + Feliratok méretének és hátterének stílusbeli módosítása. A módosítások életbelépésehez az alkalmazás újraindítása szükséges. + + LeakCanary bekapcsolása + Import/Export + Import + Importálás a következőből + Exportálás a következőbe + + Importálás… + Exportálás… + + Fájl importálása + Előző exportálás + + Sikertelen a feliratkozások importálása + Sikertelen a feliratkozások exportálása + + Importálja YouTube feliratkozásait az export fájl letöltésével: +\n +\n1. Navigáljon erre az oldalra: %1$s +\n2. Jelentkezzen be +\n3. Ekkor az export fájl letöltése megkezdődik + Importálja SoundCloud profilját az URL vagy az azonosítójának begépelésével: +\n +\n1. Az oldal mobileszközökön nem elérhető, így „asztali böngésző mód” szükséges +\n2. Navigáljon erre az oldalra: %1$s +\n3. Jelentkezzen be +\n4. Másolja ki a profil URL-t, ahova át lett irányítva. + azonosítód, soundcloud.com/azonosítód + + Ez a művelet adatforgalom igényes lehet. +\n +\nFolytatni kívánja\? + + Ütem + Hangmagasság + Előrepörgetés csend alatt + Lépés + Helyreállítás + + Elfogadás + Elutasítás + + Nincs korlát + Felbontás korlátozása mobilinternet használata esetén + Művelet alkalmazás váltásakor a fő videólejátszóról — %s + Ne tegyen semmit + Lejátszás folytatása a háttérben + Lejátszás folytatása felugró ablakban + + diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 467580fd8..1eef0ffca 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -40,7 +40,7 @@ Video berikutnya Tampilkan video \'berikutnya\' dan \'serupa\' URL tidak didukung - Bahasa konten baku + Bahasa konten baku Video & Audio Tampilan Lainnya diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e7c0dc1ee..fbc1c058a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -10,7 +10,7 @@ Scarica Cerca Impostazioni - Intendevi: %1$s ? + Intendevi %1$s? Condividi con Scegli il browser rotazione @@ -28,7 +28,7 @@ Prossimo video Mostra \'prossimi\' video e video \'simili\' URL non supportato - Lingua predefinita per i contenuti + Lingua predefinita per i contenuti Video e Audio Miniatura anteprima video @@ -74,7 +74,7 @@ Mostra contenuti vietati ai minori Questo video è riservato ad un pubblico maggiorenne. Per accedervi, abilita \"Mostra contenuti vietati ai minori\" nelle impostazioni. - Tocca cerca per iniziare + Tocca Cerca per iniziare Riproduzione automatica Riproduci i video quando NewPipe viene aperto da un\'altra app in diretta @@ -147,7 +147,7 @@ Apri in modalità popup - NewPipe modalità popup + Modalità popup di NewPipe Riproduzione in modalità popup Disattivato diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index ceee62fa9..b73d150e3 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -25,14 +25,14 @@ デフォルトの音楽形式 保存 次の動画 - \"次の動画\"と\"関連動画\"を表示します + 「次の動画」と「関連動画」を表示 URLは使用できません - 優先言語 + 優先言語 動画と音楽 %1$s ビュー 動画 プレビュー サムネイル 動画 プレビュー サムネイル - アップローダー サムネイル + 投稿者アイコンのサムネイル 低評価 高評価 外部プレイヤーを使用する @@ -43,9 +43,9 @@ Torを使用する (実験的) 強制的に Tor を経由した経路で保存して、匿名性を高めます(動画の同時再生は未だ対応していません)。 - 外観 - - + テーマ + ダーク + ライト 外観 その他 @@ -59,26 +59,26 @@ 保存場所 \'%1$s\' を作成しました エラー 全てのサムネイルを読み込むことができません - 動画のURL署名を復号できませんでした + 動画の URL 署名を復号できませんでした Webサイトを解析できませんでした コンテンツが利用できません - GEMA によって阻止されました + GEMA にブロックされました 保存メニューを設定できませんでした - 生放送には対応していません + 生放送にはまだ対応していません - 内容 + コンテンツ 年齢制限のあるコンテンツを表示する この動画には年齢制限があります。視聴するには設定から制限を解除して下さい。 - Webサイトを完全には解析できませんでした + ウェブサイトを完全には解析できませんでした 動画を取得できませんでした - 申し訳ありません。発生すべきではありませんでした。 + 申し訳ありません。発生すべきでものではありませんでした。 メールで不具合を報告 - 複数の不具合が発生しました + 申し訳ありません。いくつかのエラーが発生しました。 報告 情報: 何が起こりましたか: @@ -91,7 +91,7 @@ 再試行 ストレージへのアクセスが拒否されました 自動再生 - NewPipeが他のアプリから呼び出された際に、自動的に動画を再生します。 + NewPipe が他のアプリから呼び出された時、動画を再生します。 不具合を報告 利用者報告 @@ -100,7 +100,7 @@ 開始するには検索をタップ 開始 一時停止 - 表示 + 再生 削除 チェックサム @@ -117,7 +117,7 @@ タップして詳細を表示 お待ちください… クリップボードにコピーしました - 利用可能な保存場所を選択して下さい + ダウンロードフォルダを選択して下さい 保存 保存 @@ -126,13 +126,13 @@ 画像を読み込みできません アプリ/UI がクラッシュしました - 何:\\n提案:\\nコンテンツ言語:\\nサービス:\\nGMT 時間:\\nパッケージ:\\nバージョン:\\nOS バージョン: + 何:\\\\n提案:\\\\nコンテンツ言語:\\\\nサービス:\\\\nGMT 時間:\\\\nパッケージ:\\\\nバージョン:\\\\nOSバージョン: reCAPTCHA reCAPTCHA の要求 reCAPTCHA を要求しました - + ブラック すべて チャンネル @@ -161,7 +161,7 @@ デフォルトのポップアップ解像度 高い解像度で表示 - 一部のデバイスのみ 2K/4K ビデオの再生をサポートしています + 2K/4K ビデオの再生は一部のデバイスのみサポートしています 背景 ポップアップ @@ -175,7 +175,7 @@ ポップアップ サイズを変更 - このオプションが有効になっているとき、一部の解像度では音声がありません。 + このオプションが有効になっているとき、一部の解像度では音声がありません プレーヤーのジェスチャー コントロール ジェスチャーを使用してプレーヤーの明るさと音量をコントロールする 検索候補 @@ -193,12 +193,12 @@ このアプリについて 貢献者 ライセンス - Android向けの無料で軽量なYouTubeフロントエンド。 - Github で表示 + Android 向けの自由で軽量なプレイヤー + GitHub で表示 NewPipe のライセンス 翻訳、デザインの変更、コードの整理、動作の重いコードの変更など、アイデアをお持ちではありませんか?ヘルプはいつでも歓迎します。より良いものを一緒に作り上げましょう! ライセンスを読む - 貢献 + 貢献する チャンネル登録 チャンネル登録しました チャンネル登録を解除しました @@ -212,14 +212,14 @@ 検索履歴 検索履歴をローカルに保存します - 再生履歴 + 再生履歴とキャッシュ 再生した動画を記録します フォーカスで再開 電話などによる中断の後、再生を再開します プレーヤー 動画の詳細ページで背景、またはポップアップボタンが押されたときにヒントを表示する 動作 - 履歴 + 履歴とキャッシュ 再生リスト 元に戻す すべて再生 @@ -227,7 +227,7 @@ NewPipeの通知 [不明] - 動画の再生に失敗しました + 動画の再生ができませんでした 回復不能な再生エラーが発生しました 何も見つかりませんでした チャンネル登録なし @@ -241,9 +241,9 @@ ほとんどの特殊文字 寄付 - NewPipeはあなたに最高の経験をもたらすため、自由時間を費やしたボランティアによって開発されています。カップのコーヒー(Java)を楽しんでいる間に、開発者がNewPipeをより良いものにすることが出来るよう、今度はお返しをする時間です! + NewPipe は、あなたに最高の体験を味わってもらうために、ボランティアが自分たちの時間を使って開発しています。開発者たちがコーヒーを飲みながら NewPipe を継続的に改良できるよう、あなたのご支援をお願いします。 Webサイト - NewPipeに関する詳しい情報や最新のニュースについては、我々のWebサイトをご覧ください。 + NewPipe の詳しい情報や最新情報については、ウェブサイトをご覧ください。 履歴 検索履歴 再生履歴 @@ -285,13 +285,13 @@ データベースのインポート データベースのエクスポート 既存の履歴と購読リストは上書きされます - 履歴や購読リスト、プレイリストをエクスポートします。 + 履歴や購読リスト、プレイリストをエクスポートします 再生エラーからの回復中 外部プレーヤーは、これらのタイプのリンクをサポートしていません 無効なURL エクスポートが完了しました インポートが完了しました - 有効なZipファイルがありません + 有効な ZIP ファイルではありません 警告: すべてのファイルをインポートできませんでした。 これにより、現在の設定が上書きされます。 @@ -307,7 +307,7 @@ 常に尋ねる 情報を取得しています… - 要求したコンテンツを読み込んでいます + コンテンツを読み込んでいます 動画ファイルをダウンロード 情報を表示 @@ -353,10 +353,10 @@ ブックマークを削除 このプレイリストを削除しますか? - プレイリストが正常に作成されました + プレイリストが作成されました プレイリストに追加しました プレイリストのサムネイルを変更しました - プレイリストの削除に失敗しました + プレイリストが削除できませんでした 字幕なし @@ -372,8 +372,8 @@ ファイルからインポート 前回のエクスポート - 購読リストのインポートに失敗しました - 購読リストのエクスポートに失敗しました + 購読リストがインポートできませんでした + 購読リストがエクスポートできませんでした テンポ 音程 @@ -381,14 +381,92 @@ バックグラウンド再生リストに追加されました ポップアップ再生リストに追加されました 再生履歴を消去 - 再生した動画の履歴を削除します。 + 再生した動画の履歴を削除します 再生履歴を削除しました。 検索履歴を消去 - 検索キーワードの履歴を削除します。 + 検索キーワードの履歴を削除します 検索履歴を削除しました。 このファイルを再生するためのアプリがインストールされていません 設定をインポートしますか? 字幕 - + チャンネル + プレイリスト + ユーザー + NewPipe プライバシーポリシー + プライバシーポリシーを確認 + おおまかなシーク + おおまかなシークを使用すると、正確さ下がりますが、高速なシークが可能になります + すべてのサムネイルの読み込みと保存を無効化します。このオプションを切り替えるとメモリおよびディスク上の画像キャッシュがクリアされます + 繰り返しでないキューの最後の動画を再生時、関連動画を自動的にキューに追加する + すべての再生履歴を削除します + すべての検索履歴を削除します + ファイル/コンテンツのソースが無効です + + 登録者数 %s 人 + + + 視聴なし + + 視聴回数 %s 回 + + + + %s 本の動画 + + + 1 つのアイテムが削除されました + + 支援する + NewPipe プロジェクトはあなたのプライバシーを非常に大切にしています。あなたの同意がない限り、アプリはいかなるデータも収集しません。NewPipe のプライバシー・ポリシーでは、クラッシュリポート送信時にどのような種類のデータが送信・記録されるかを詳細に説明しています。 + NewPipe は著作権が自由のソフトウェアです。自由であるとは、あなたは自分の自由意志で、ソフトウェアを自由に使ったり、自由に研究したり、自由に改良することができるということです。あなたは、GNU フリーソフトウェア財団が公開する GNU General Public ライセンス バージョン3以降の下に、自由に再配布・修正を行うことができます。 + 最終再生日時 + 最も再生した動画 + + ズーム + + 追加... + + 「長押しして追加」のヒントを表示 + トラック + NewPipe バックグラウンドおよびポップアップのプレーヤーの通知 + + 新しい & ホット + 長押ししてキューに入れる + バックグラウンド時にキューに入れる + ポップアップ時にキューに入れる + ポップアップ時にここから開始 + + すぐにここに表示されます;D + + + お好みの \'開く\' アクション + コンテンツを開くときのデフォルト動作 — %s + + フィット + 埋める + 自動生成 + + プレーヤーのキャプション文字スケールと背景スタイルを変更します。有効にするにはアプリの再起動が必要です + + 何もありません + 保存したエクスポートファイルからYoutubeの購読をインポート: +\n +\n1. このURLを開きます: %1$s +\n2. ログインしていなければログインします +\n3. ダウンロードが始まります (これがエクスポートファイルです) + リセット + + 同意する + 拒否 + + 制限なし + モバイルデータ使用時の解像度の制限 + アプリ切り替え時の最小化 + プレーヤーから他アプリに切り替え時の動作 — %s + 何もしない + バックグラウンドに変更 + ポップアップに変更 + + diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d00ee12ea..34eb53c8a 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -28,7 +28,7 @@ 다음 비디오 다음 및 유사한 비디오 표시 지원하지 않는 URL 입니다 - 기본 컨텐츠 언어 + 기본 컨텐츠 언어 비디오 & 오디오 비디오 미리보기 썸네일 @@ -60,7 +60,7 @@ 다운로드 디렉토리를 만들었습니다 \'%1$s\' 검색 버튼을 눌러서 시작하세요 자동으로 재생 - NewPipe가 다른 앱으로부터 호출되었을 경우 비디오를 자동으로 재생합니다 + NewPipe가 다른 앱으로부터 호출되었을 때 동영상을 재생합니다 컨텐츠 나이 제한이 있는 컨텐츠를 표시 연령 제한 비디오입니다. 설정 메뉴에서 시청 허용 여부를 변경하실 수 있습니다. @@ -130,7 +130,7 @@ 검색 중에 제안을 표시합니다 검색 기록 검색 기록을 기기에 저장합니다 - 기록 + 기록 및 캐시 관리 시청했던 비디오 기록을 저장 초점 복원시 재생 재개 전화 통화 등으로 인해 재생이 중단된 이후에 다시 재생을 시작합니다 @@ -165,7 +165,7 @@ 이미지를 불러올 수 없습니다 앱/UI 충돌 - 이 스트림을 재생할 수 없습니다 + 이 스트림을 재생할 수 없습니다. 복구할 수 없는 플레이어 오류가 발생했습니다 플레이어 오류로부터 복구 중 @@ -234,7 +234,7 @@ 뉴파이프 기여자 라이센스 - 안드로이드를 위한 개방/자유/무료 유튜브 프론트엔드 입니다. + 가볍고 빠른 자유 안드로이드 스트리밍 앱입니다. 기여 번역, 디자인, 코딩 등 다양한 기여를 언제나 환영합니다. 향상에 참여해주세요! GitHub에서 보기 @@ -293,7 +293,7 @@ 정확하지는 않지만 빠른 탐색 정확하지 않은 탐색은 빠르게 위치로 탐색할 수 있지만 정확도는 떨어집니다 다음 스트림을 자동으로 재생열에 추가하기 - 전 스트림이 무한 반복 재생 큐가 아닐 때 관련된 스트림 자동 재생하기. + 이전 스트림이 무한 반복 재생 큐가 아닐 경우, 관련 스트림을 자동 재생합니다. 기본 콘텐츠 국가 서비스 디버그 @@ -309,7 +309,7 @@ 데이터베이스 가져오기 데이터베이스 내보내기 현재 시청 기록 및 구독 목록을 덮어쓰기 됩니다 - 시청 기록, 구독 목록, 재생목록 내보내기. + 시청 기록, 구독 목록과 재생목록을 내보냅니다. 외부 플레이어는 이러한 종류의 링크를 지원하지 않습니다 잘못된 URL 발견된 비디오 스트림 없음 @@ -359,7 +359,7 @@ 북마크 제거하기 이 재생목록을 삭제하시겠습니까? - 재생목록 생성 성공 + 재생목록 생성 완료 재생목록에 추가됨 재생목록 썸내일이 바뀜 재생목록 삭제 실패 @@ -379,9 +379,9 @@ 동기화 LeakCanary 할성화 - 메모리 누수 모니터링은 힙 덤핑시 앱이 불안정할 수 있습니다 + 힙 덤프 중 메모리 누수 점검으로 앱이 불안정해질 수 있습니다 - Out-of-Lifecycle 에러 보고 + Out-of-Lifecycle 오류 보고 프래그먼트 또는 버려진 액티비티 주기 밖에서 일어나는 전달할 수 없는 Rx 예외를 강제적으로 보고하기 파일 @@ -410,21 +410,21 @@ \n1. 이곳으로 가세요: $1$s \n2. 로그인이 필요하면 하세요 \n3. 다운로드가 곧 시작 됩니다 (이 파일이 내보내기 파일 입니다) - SoundCloud 팔로잉 목록을 가져오려면 당신의 프로필 URL 및 ID를 알아야 합니다. 알고 있다면 아래에 있는 빈칸에 입력해 주세요. + SoundCloud 프로필을 가져오시려면 URL 및 ID를 입력해주세요. +\n +\n프로필 URL을 찾으시려면 다음 과정을 따라해 주세요. \n -\n만약 모르신다면, 다음을 참고하세요: -\n -\n1. 모바일 환경이시면 브라우저 설정에서 데스크탑 모드를 활성화해주세요. Chrome 모바일에서는 오른쪽 … 클릭시 아래쪽에 있습니다. \n2. 이 주소로 가세요: %1$s -\n3. 로그인이 필요하면 하세요. +\n3. 로그인이 필요하면 하세요. \n4. 리디렉트된 곳의 URL을 복사하세요. (이 URL이 당신의 프로필 URL 입니다) 프로필ID, soundcloud.com/프로필ID - 경고: 데이터 소모량이 늘어날 수 있습니다. -\n -\n진행하시겠습니까? + 경고: 데이터 소모량이 늘어날 수 있습니다. +\n +\n계속하시겠습니까? 썸내일 로드하기 - 모든 썸내일의 로드와 데이터에 저장하기를 멈춥니다. 이것은 메모리와 이미지 캐시를 지울 것입니다. + 동영상 썸네일을 로드하지 않으며, 데이터와 메모리 사용을 최대한 줄입니다. 이 옵션을 +\n선택 시, 모든 메모리 캐시와 저장소 캐시를 삭제합니다. 이미지 캐시 지워짐 캐시된 메타데이터 지우기 캐시된 모든 웹페이지 데이터 지우기 @@ -432,12 +432,12 @@ 재생 속도 조절 템포 피치 - 후크 취소 (소리가 깨질수 있음) + 영상과 소리 분리 (소리가 깨질 수 있음) 나이트코어 기본 다운로드 가능한 스트림이 없습니다 - 이 파일을 위한 플레이어가 발견되지 않았습니다 + 이 파일을 재생할 수 있는 플레이어 앱이 없습니다. 선호하는 열기 동작 컨텐츠를 열 때 사용할 기본 동작 — %s @@ -445,4 +445,29 @@ 자막 플레이어 자막 텍스트 크기와 배경 스타일을 변경합니다. 효과를 적용하려면 앱을 재시작 해야합니다. + 채널만 + 재생 목록만 + 시청 기록 삭제하기 + 동영상 시청 기록을 삭제합니다. + \'삭제\' 버튼을 누르면 모든 시청 기록이 삭제됩니다. + 동영상 시청 기록이 삭제되었습니다. + 검색 기록 삭제 + 검색 기록을 모두 삭제합니다. + \'삭제\' 버튼을 누르면 모든 검색 기록이 삭제됩니다. + 검색 기록이 삭제되었습니다. + 뉴파이프 개인정보 보호 정책 + 뉴파이프 프로젝트는 사용자의 개인 정보 보호를 최우선으로 생각하며, 동의 없이 어떠한 정보도 수집하지 않습니다. +\n뉴파이프 개인정보 보호 정책에서는 오류 보고 시 어떠한 정보가 수집되고 저장되는지 자세히 명시되어 있습니다. + 개인정보 보호 정책 읽기 + 뉴파이프는 카피레프트 자유 소프트웨어입니다. 사용자는 이 앱을 사용, 공유, 또는 수정하는 것이 가능하고, 수정 후 재배포 시 자유 소프트웨어 재단의 GNU 라이센스 버전 +\n3 또는 그 이상의 버전을 포함해야 합니다. + 앱 설정을 가져오시겠습니까? + + 무음 구간 스킵 + 유럽 연합 일반 데이터 보호 규정 (GDPR) 에 따라, 사용자는 뉴파이프 개인정보 보호 정책을 읽고 꼼꼼히 확인해야 합니다. 버그 리포트를 보내시려면 개인정보 보호 정책에 동의해주세요. + 동의 + 동의하지 않음 + + 데이터 제한 없음 + 모바일 데이터 사용 시 화질 제한 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index af2ff5a7a..4ffcd26d6 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -57,7 +57,7 @@ Kitas vaizdo įrašas Rodyti kitus panašius vaizdo įrašus URL nepalaikoma - Numatytoji tūrinio kalba + Numatytoji tūrinio kalba Vaizdas ir garsas Iššokantis langas Išvaizda diff --git a/app/src/main/res/values-mk/strings.xml b/app/src/main/res/values-mk/strings.xml index b6e79dbd4..812bd1534 100644 --- a/app/src/main/res/values-mk/strings.xml +++ b/app/src/main/res/values-mk/strings.xml @@ -93,7 +93,7 @@ Неподдржана URL врска Земја на прикажани видеа Услуга - Претпочитан јазик на видеата + Претпочитан јазик на видеата Плеер Однесување Видео и Звук diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index da4f099cd..4bb790b1b 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -40,7 +40,7 @@ Neste video Vis \"neste\" og \"lignende\" videoer Nettadressen støttes ikke - Foretrukket innholdsspråk + Foretrukket innholdsspråk Video og lyd Utseende Annet @@ -377,7 +377,7 @@ Mindre skrift Normal skrift Større skrift -Bruk raskt unøyaktig søk +Bruk raskt unøyaktig blafring Feilretting Fil @@ -434,7 +434,7 @@ Søkehistorikk slettet. Ett element slettet. - Inget program installert for avspilling av denne filen. + Inget program installert for avspilling av denne filen Undertekster Endre undertekststørrelse og bakgrunnsstiler. Krever omstart av programmet for å tre i effekt diff --git a/app/src/main/res/values-nl-rBE/strings.xml b/app/src/main/res/values-nl-rBE/strings.xml index 18b23120d..9f1d796fd 100644 --- a/app/src/main/res/values-nl-rBE/strings.xml +++ b/app/src/main/res/values-nl-rBE/strings.xml @@ -93,7 +93,7 @@ URL wordt niet ondersteund Standaardinhoudsland Dienst - Standaardtaal voor inhoud + Standaardtaal voor inhoud Speler Gedrag Video & audio diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index c4bb1e7d1..32a361205 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -5,38 +5,38 @@ Geen speler met streamondersteuning gevonden. Wil je VLC installeren? Installeren Annuleren - In browser openen + Openen in browser Delen Downloaden Zoeken Instellingen - Bedoelde je: %1$s ? + Bedoelde je: %1$s\? Delen met Kies een browser - rotatie + draaiing Downloadlocatie voor video\'s Locatie om gedownloade video\'s in op te slaan Voer downloadlocatie in voor video\'s Standaardresolutie Afspelen met Kodi Kore-app niet gevonden. Installeren? - Toon \"Afspelen met Kodi\"-optie - Toont een optie om een video op een Kodi media center af te spelen + \"Afspelen met Kodi\"-optie tonen + Toont een optie om een video af te spelen op een Kodi-mediastation Audio Standaardaudioformaat Downloaden Volgende video URL wordt niet ondersteund ‘volgende’ en ‘vergelijkbare’ video’s weergeven - Standaardtaal voor inhoud + Standaardtaal voor inhoud Externe videospeler gebruiken Externe audiospeler gebruiken - Video & audio + Audio en video Videovoorbeeldminiatuur Videovoorbeeldminiatuur - Avatarminiatuur van uploader - Duimpjes omlaag + Gebruikersafbeelding van uploader + Vind-ik-niet-leuks Downloadlocatie voor audio Locatie om gedownloade muziek in op te slaan Voer downloadlocatie voor audiobestanden in @@ -46,38 +46,38 @@ Licht Uiterlijk - Overige - Speelt af in achtergrond + Overig + Speelt af op achtergrond Afspelen Inhoud - Toon inhoud met leeftijdsbeperking - Video heeft leeftijdsbeperking. Leeftijdsbeperkte video\'s kunnen ingeschakeld worden in de instellingen. + Inhoud met leeftijdsbeperking tonen + De video heeft een leeftijdsbeperking. Toestaan van dit soort video\'s kan worden ingeschakeld in de instellingen. Fout Netwerkfout - Kon niet alle miniaturen laden - Kon video-URL-ondertekening niet ontsleutelen - Kon website niet verwerken + Kan niet alle miniatuurvoorbeelden laden + Kan video-URL-ondertekening niet ontsleutelen + Kan website niet verwerken Inhoud niet beschikbaar Geblokkeerd door GEMA - Kon downloadmenu niet instellen - Dit is een LIVESTREAM, deze worden nog niet ondersteund. + Kan downloadmenu niet instellen + Dit is een LIVESTREAM; deze worden nog niet ondersteund. - Duimpjes omhoog - Gebruik Tor + Vind-ik-leuks + Tor gebruiken (Experimenteel) Dwing downloadverkeer door Tor voor verhoogde privacy (streamen van video\'s wordt nog niet ondersteund). - Kan downloadmap \'%1$s\' niet aanmaken - Downloadmap \'%1$s\' aangemaakt - Kon de website niet volledig inlezen - Kon geen streams vinden - Sorry, dit zou niet mogen gebeuren. + Kan downloadmap \'%1$s\' niet creëren + Downloadmap \'%1$s\' gecreëerd + Kan website niet volledig verwerken + Kan geen streams vinden + Sorry, dit had niet mogen gebeuren. Fout melden via e-mail Sorry, er traden enkele fouten op. MELDEN - Info: - Wat is er gebeurd: + Informatie: + Wat er is gebeurd: Je opmerking (in het Engels): Details: @@ -85,7 +85,7 @@ Video Geluid Opnieuw proberen - Tik op zoeken om te beginnen + Druk op zoeken om te beginnen Automatisch afspelen Speelt video’s af als NewPipe vanuit een andere app wordt geopend live @@ -93,7 +93,7 @@ Downloads Foutrapport - Kon afbeelding niet laden + Kan afbeelding niet laden App/UI gecrasht Wat:\\nVerzoek:\\nTaal van inhoud:\\nDienst:\\nTijd in GMT:\\nPakket:\\nVersie:\\nVersie van besturingssysteem: Meld een probleem @@ -115,9 +115,9 @@ Fout Server wordt niet ondersteund Bestand bestaat al - Verkeerde URL of internet niet beschikbaar + Onjuiste URL of geen internetverbinding NewPipe is aan het downloaden - Tik voor meer informatie + Druk voor meer informatie Even geduld… Gekopieerd naar klembord Kies een beschikbare downloadmap @@ -140,21 +140,21 @@ M B - Deze toestemming is vereist om -te openen in pop-upmodus + Deze machtiging is vereist om te +\nopenen in pop-upmodus NewPipe-pop-upmodus Speelt af in pop-upmodus - Gebruik oude speler + Oude speler gebruiken Verouderde ingebouwde Mediaframework-speler - Standaardvideoformaat + Standaard videoformaat Uitgeschakeld - Standaardresolutie voor pop-up - Hogere resoluties weergeven - Video\'s afspelen in 2K/4K wordt maar op sommige apparaten ondersteund + Standaardresolutie van pop-up + Hogere resoluties tonen + 2K-/4K-video\'s worden slechts op sommige apparaten ondersteund Achtergrond Pop-up @@ -166,9 +166,9 @@ te openen in pop-upmodus Onthoud laatste grootte en positie van pop-up Pop-up - Bezig met schalen + Bezig met wijzigen van grootte - Sommige resoluties zullen geen geluid hebben als deze optie is ingeschakeld + Sommige resoluties hebben GEEN geluid hebben als deze optie is ingeschakeld Zoeksuggesties Toon suggesties bij zoeken @@ -179,9 +179,9 @@ te openen in pop-upmodus Over NewPipe Instellingen Over - Derdepartijlicenties + Licenties van derde partijen © %1$s door %2$s, uitgebracht onder de %3$s - Kon licentie niet laden + Kan licentie niet laden Website openen Over Bijdragers @@ -189,7 +189,7 @@ te openen in pop-upmodus Vrij en licht streamen voor Android. Bekijken op GitHub Licentie van NewPipe - Hulp is altijd welkom, of je nu nieuwe ideeën hebt, vertalingen kan bijdragen, wijzigingen in het ontwerp, opschonen van of grote wijzigingen in de code. Hoe meer hulp, hoe beter het wordt! + Hulp is altijd welkom. Of je nu nieuwe ideeën hebt, vertalingen kan aanleveren, wijzigingen in het ontwerp kan verrichten, code kan opschonen of van grote wijzigingen voorzien. Hoe meer hulp, hoe beter het wordt! Licentie lezen Bijdragen Download @@ -198,15 +198,15 @@ te openen in pop-upmodus Vervangend teken Letters en cijfers - Meeste speciale tekens + Speciaalste tekens - Abonneer + Abonneren Geabonneerd - Abonnement opgezegd - Kan het abonnement niet veranderen - Kan het abonnement niet vernieuwen + Gedeabonneerd + Kan abonnement niet wijzigen + Kan abonnement niet bijwerken - Start + Startpagina Abonnementen Nieuw @@ -224,7 +224,7 @@ te openen in pop-upmodus Geschiedenis Gezocht - Gekeken + Bekeken Geschiedenis is uitgeschakeld Geschiedenis De geschiedenis is leeg @@ -232,12 +232,12 @@ te openen in pop-upmodus Speler Gedrag - Geschiedenis & Cache + Geschiedenis en cache Afspeellijst Ongedaan maken Geen resultaten - Niets te zien + Niets, maar dan ook niets te zien Geen abonnees @@ -258,29 +258,29 @@ te openen in pop-upmodus Item verwijderd -Wil je dit item uit je zoekgeschiedenis verwijderen? -Tip weergeven voor ingedrukt houden om toe te voegen - Toon tip wanneer achtergrond- of pop-upknop is ingedrukt op de video-detailpagina +Wil je dit item verwijderen uit je zoekgeschiedenis? +Tip tonen voor ingedrukt houden om toe te voegen + Toon tip als achtergrond- of pop-upknop wordt ingedrukt op de videogegevenspagina Toegevoegd aan wachtrij voor achtergrondspeler Toegevoegd aan wachtrij voor pop-upspeler Alles afspelen Deze stream kan niet worden afgespeeld Onherstelbare spelerfout opgetreden - Aan het herstellen van spelerfout + Bezig met herstellen na spelerfout - Content van hoofdpagina - Blanke Pagina + Inhoud van hoofdpagina + Blanco pagina Kioskpagina Abonnementenpagina Feedpagina Kanaalpagina - Selecteer een kanaal + Kies een kanaal Nog niet geabonneerd op een kanaal - Selecteer een kiosk + Kies een kiosk Kiosk - Trending + Populair Top 50 Nieuw en populair Achtergrondspeler @@ -293,28 +293,28 @@ te openen in pop-upmodus Toevoegen aan wachtrij in achtergrond Toevoegen aan wachtrij in pop-up - Hier beginnen spelen - Hier beginnen in achtergrond - Hier beginnen in pop-up + Begin hier met afspelen + Begin hier met afspelen op achtergrond + Begin hier met afspelen in pop-up Doneren NewPipe wordt door vrijwilligers in hun vrije tijd ontwikkeld om jou de beste ervaring te brengen. Geef wat terug zodat onze ontwikkelaars NewPipe nóg beter kunnen maken terwijl ze van hun kopje koffie genieten. Teruggeven Website Bezoek de website van NewPipe voor meer informatie en het laatste nieuws. - Standaardinhoudsland + Standaard inhoudsland Dienst Oriëntatie wijzigen Verplaatsen naar achtergrond Verplaatsen naar pop-up - Verplaatsen naar normaal + Verplaatsen naar hoofdvenster Menu openen Menu sluiten - Geen speler met streamondersteuning gevonden (je kan VLC installeren om het af te spelen) + Geen speler met streamondersteuning gevonden (je kan VLC installeren om af te spelen) Altijd Eenmalig - Externe spelers ondersteunen deze soorten koppelingen niet + Externe spelers ondersteunen dit soort links niet Ongeldige URL Geen videostreams gevonden Geen audiostreams gevonden @@ -324,20 +324,20 @@ te openen in pop-upmodus Pop-upspeler Altijd vragen - Info ophalen… + Bezig met ophalen van informatie… Bezig met laden van gevraagde inhoud -Database importeren - Database exporteren - Dit zal je huidige geschiedenis en abonnementen overschrijven - Exporteer geschiedenis, abonnementen en speellijsten - Export voltooid - Import voltooid +Databank importeren + Databank exporteren + Dit overschrijft je huidige geschiedenis en abonnementen + Exporteer geschiedenis, abonnementen en afspeellijsten + Exporteren voltooid + Importeren voltooid Geen geldig ZIP-bestand - Opgelet: kon niet alle bestanden importeren. - Dit zal je huidige configuratie overschrijven. + Let op: niet alle bestanden konden worden geïmporteerd. + Dit overschrijft je huidige configuratie. Streambestand downloaden. - Info tonen + Informatie tonen Bladwijzers @@ -345,22 +345,22 @@ te openen in pop-upmodus Versleep om de volgorde te wijzigen - Aanmaken - Één verwijderen + Creëren + Eén verwijderen Alles verwijderen Sluiten - Hernoemen + Naam wijzigen - Wil je dit item uit je kijkgeschiedenis verwijderen? - Wil je alle items uit je geschiedenis verwijderen? + Wil je dit item verwijderen uit je kijkgeschiedenis? + Wil je alle items verwijderen uit je geschiedenis? Laatst afgespeeld Meest afgespeeld Altijd vragen - Nieuwe afspeellijst aanmaken + Nieuwe afspeellijst creëren Afspeellijst verwijderen - Afspeellijst hernoemen + Afspeellijstnaam wijzigen Naam Toevoegen aan afspeellijst Instellen als miniatuur voor afspeellijst @@ -376,7 +376,7 @@ te openen in pop-upmodus Geen bijschriften - Passen + Inpassen Opvullen Inzoomen @@ -404,8 +404,8 @@ te openen in pop-upmodus Ongeldige map Ongeldig bestand/Ongeldige inhoudsbron - Het bestand bestaat niet of u beschikt niet over voldoende machtiging om het te lezen/er naar te schrijven - De bestandsnaam mag niet leeg zijn + Het bestand bestaat niet of je bent onvoldoende gemachtigd om het te lezen/er naar te schrijven + De bestandsnaam mag niet blanco zijn Er is een fout opgetreden: %1$s Importeren/Exporteren @@ -435,7 +435,7 @@ te openen in pop-upmodus \n4. Kopieer de koppeling van de pagina waar je op terechtkomt (dat is je profiel-URL). jouwID, soundcloud.com/jouwid - Let op: deze actie kan veel MB’s van je netwerk gebruiken. + Let op: deze actie kan veel MB’s van je mobiele netwerk gebruiken. \n \nWil je doorgaan? Miniatuurvoorbeelden laden @@ -456,7 +456,7 @@ te openen in pop-upmodus Geen streams beschikbaar voor downloaden Bijschriften - Bijschriftgrootte en achtergrondstijlen wijzigen. Vereist een herstart van de app + Bijschriftgrootte en -achtergrondstijlen wijzigen. Vereist een herstart van de app Er is geen app geïnstalleerd die dit bestand kan afspelen @@ -464,22 +464,22 @@ te openen in pop-upmodus Verwijdert de geschiedenis van afgespeelde streams Verwijdert de gehele kijkgeschiedenis. - Kijkgeschiedenis verwijderd. + Kijkgeschiedenis gewist. Zoekgeschiedenis wissen Verwijdert de gebruikte zoektermen Verwijdert de gehele geschiedenis. - Zoekgeschiedenis verwijderd. + Zoekgeschiedenis gewist. 1 item verwijderd. NewPipe is vrije software: je kan het gebruiken, bestuderen, delen en verbeteren zoveel je maar wil. Je kan het opnieuw uitgeven en/of aanpassen volgens de voorwaarden van de GNU General Public License, gepubliceerd door de Free Software Foundation, versie 3 van de licentie, of (indien gewenst) om het even welke latere versie. Wil je ook de instellingen importeren? NewPipe\'s privacybeleid - Het NewPipe-project neemt privacy serieus. Daarom verzamelt de app geen gegevens zonder jouw toestemming. -\nNewPipe\'s privacybeleid legt gedetailleerd uit welke gegevens verstuurd en opgeslagen worden als je een crashrapport verstuurd. + Het NewPipe-project neemt privacy serieus. Daarom verzamelt de app geen gegevens zonder jouw toestemming. +\nNewPipe\'s privacybeleid legt gedetailleerd uit welke gegevens verstuurd en opgeslagen worden als je een crashrapport verstuurt. Privacybeleid lezen - Om de Europese Algemene Verordening Gegevensbescherming (ook wel: AVG of GDPR) na te leven, wijzen we je op het nieuwe privacybeleid van NewPipe. Lees dit zorgvuldig. -\nJe moet het beleid accepteren om ons het bugrapport te kunnen sturen. + Om de Europese Algemene Verordening Gegevensbescherming (ook wel: AVG of GDPR) na te leven, wijzen we je op het nieuwe privacybeleid van NewPipe. Lees dit zorgvuldig. +\nJe moet het beleid accepteren om ons het foutrapport te kunnen sturen. Accepteren Weigeren Ongelimiteerd diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 560284f6a..c307caead 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -13,9 +13,19 @@ ਡਾਊਨਲੋਡ ਸਟਰੀਮ ਫਾਈਲ. ਖੋਜੋ ਸੇਟਿੰਗਾਂ - ਕੀ ਤੁਹਾਡਾ ਮਤਲਬ: %1$s ? + ਕੀ ਤੁਹਾਡਾ ਮਤਲਬ: %1$s\? ਭੇਜੋ Browser ਚੁਣੋ ਉਲਟਾਨਾ - ਹੋਰ ਪਲੇਅਰ ਵਰਤਣਾ + ਹੋਰ ਪਲੇਅਰ ਵਰਤਤੋ + ਕੁਝ ਵੀਡੀਓ ਰੈਸੋਲੂਸ਼ਨ ਚੁਣਨ ਨਾਲ ਆਡੀਓ ਮੌਜੂਦ ਨਹੀਂ ਹੋਵੇਗੀ + ਬਾਹਰੀ ਆਡੀਓ ਪਲੇਅਰ ਦੀ ਵਰਤੋਂ ਕਰੋ + NewPipe ਪੋਪਉਪ ਮੋਡ + ਸਅਬਸਕਰਾਇਬ + ਮੈਂਬਰ ਬਣਏ + ਚੈਨਲ ਸਦੱਸਤਾ ਰੱਦ ਕੀਤੀ ਗਈ + ਸਦੱਸਤਾ ਨੂੰ ਬਦਲਣ ਵਿਚ ਅਸਮਰੱਥ ਹੈ + ਜਾਣਕਾਰੀ + + ਮੁੱਖ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index fa885e51b..d25acce8d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -43,7 +43,7 @@ Następny film Pokaż \'następne\' i \'podobne\' filmy URL nieobsługiwany - Domyślny język zawartości + Domyślny język zawartości Wideo i audio Wygląd Inne @@ -144,7 +144,7 @@ Kanał Tak Później - Wyłączone + Wyłączony Filtr Odśwież Wyczyść @@ -481,4 +481,18 @@ Brak limitu Limit przy użyciu danych mobilnych - + Kanały + Playlisty + Utwory + Użytkownicy + Przewiń w przód podczas ciszy + Krok + Zresetuj + + Zminimalizuj podczas przełączenia aplikacji + "Akcja podczas przełączenia do innej aplikacji z głównego odtwarzacza — %s" + Zminimalizuj + Zminimalizuj do odtwarzania w tle + Zminimalizuj do odtwarzania w okienku + + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4e08f4ee5..82a54c90c 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -57,7 +57,7 @@ Reportar um erro Tentar novamente Rotação - Idioma de conteúdo preferido + Idioma de conteúdo preferido Configurações Aparência Outros @@ -254,7 +254,7 @@ abrir em modo popup Em Alta Top 50 Novos e tendências -"Mostrar dica \"mantenha pressionado\" para enfileirar" +"Mostrar dica \"mantenha pressionado para enfileirar\"" Mostrar dica quando o botão de plano de fundo ou de popup for pressionado na página de detalhes do vídeo Adicionado a fila do reprodutor em plano de fundo Adicionado a fila no reprodutor popup @@ -291,7 +291,7 @@ abrir em modo popup Alterar a orientação Alterar para Plano de Fundo Alterar para Popup - Aletar para principal + Alterar para o Principal Reprodutores externos não suportam estes tipos de links URL inválida diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d8ca1a265..b27715fa3 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -30,7 +30,7 @@ Vídeo seguinte Mostrar vídeos \'seguintes\' e \'semelhantes\' URL não suportado - Idioma padrão do conteúdo + Idioma padrão do conteúdo Vídeo e áudio Miniatura de vídeos @@ -184,7 +184,7 @@ Sobre Colaboradores Licenças - Aplicação leve, simples e grátis de YouTube para Android. + Aplicação leve livre de YouTube para Android. Ver no GitHub Licença do NewPipe Se tem ideias de tradução, alterações de design, limpeza de código ou alterações de código pesado—ajuda é sempre bem-vinda. Quanto mais se faz melhor fica! @@ -293,7 +293,7 @@ Importar base de dados Exportar base de dados Irá sobrepor o seu histórico atual e subscrições - Exportar histórico, subscrições e listas de reprodução. + Exportar histórico, subscrições e listas de reprodução Em lista de espera no reprodutor em segundo plano Em lista de espera no reprodutor popup Mudar para segundo plano @@ -318,7 +318,7 @@ Utilizar pesquisa rápida A pesquisa rápida permite que a pesquisa seja mais rápida mas diminui a qualidade da precisão Carregar miniaturas - Desative para parar o carregamento das miniaturas e poupar dados e memória. Se alterar esta opção limpa a cache de memória e do disco. + Desative para parar o carregamento das miniaturas e poupar dados e memória. Se alterar esta opção limpa a cache de memória e do disco Cache de imagens limpa País padrão para o conteúdo Depuração @@ -333,7 +333,7 @@ Descartar Site - Para obter mais informações e saber as novidades do NewPipe, aceda ao nosso site. + Visite ao website NewPipe para obter mais informações e saber as novidades. Página \"kiosk\" Página da fonte Exportação terminada @@ -364,7 +364,7 @@ Perguntar sempre A obter informação… - O conteúdo requisitado está a carregar + O conteúdo requisitado está carregando Criar Nova Lista de Reprodução Apagar Lista de Reprodução @@ -379,7 +379,7 @@ Thumbnail da Lista de Reprodução modificada Sem Legenda - ZOOM + Zoom Gerado automaticamente @@ -398,24 +398,47 @@ Importação de subscrições falhou Exportação de subscrições falhou - Para importar as tuas subscrições do Google vais precisar do ficheiro de exportação, que pode ser descarregado com auxílio destas instruções: -\n -\n1. Vai a esta hiperligação: %1$s -\n2. Inicia a tua sessão quando requisitado -\n3. O descarregamento deve começar (esse é o ficheiro de exportação) - Para importar as contas que segue no SoundCloud, terá que saber o link ou id do seu perfil. Se souber, basta escrever um deles no campo abaixo e estará tudo pronto. + Para importar as tuas subscrições do Youtube vais precisar do ficheiro de exportação, que pode ser descarregado com auxílio destas instruções: \n -\nSe não souber, pode seguir estas etapas: +\n1. Vai a esta hiperligação: %1$s +\n2. Inicia a tua sessão quando requisitado +\n3. O descarregamento deve começar (esse é o ficheiro de exportação) + Para importar as contas SoundCloud, vais precisar do link ou id do seu perfil que pode ser descarregado com auxílio destas instruções: \n \n1. Ative \"modo desktop\" num navegador da internet (o site não está disponível para dispositivos móveis) \n2. Vá a este url: %1$s \n3. Inicie sessão na sua conta quando solicitado -\n4. Copie o link para o qual foi redirecionado (este é o link do seu perfil) - seuid, soundcloud.com/seuid +\n4. Copie o link para o qual foi redirecionado. + seuID, soundcloud.com/seuID Controlo de velocidade de reprodução Tempo Nightcore Predefinido Limpar histórico de exibição + Auto anexar um fluxo relacionado quando jogar o último fluxo em uma fila não repetitiva + Mostrar dica \"mantenha pressionado para enfileirar\" + Mostrar dica quando o botão de plano de fundo ou de popup for pressionado na página de detalhes do vídeo + Canais + Listas de reprodução + Faixas + Utilizadores + Deleta o histórico de videos já reproduzidos + Deleta o histórico de videos já reproduzidos. + Histórico de já assistidos deletado. + Deleta histórico de pesquisa + Deleta histórico de palavras chave pesquisadas + Deleta histórico de pesquisa completo. + Histórico de pesquisa deletado. + 1 elemento deletado. + + Nehum aplicativo instalada para reproduzir este arquivo + + NewPipe é desenvolvido por voluntários que usam seu tempo para trazer a melhor experiência para você. Retribua para ajudar os desenvolvedores a tornarem o NewPipe ainda melhor enquanto desfrutam uma xícara de café. + Retribuir + Política de privacidade do NewPipe + O projeto NewPipe leva a sua privacidade muito a sério. Sendo assim, o aplicativo não coleta nenhum dado sem seu consentimento. +\nA polícia de privacidade do NewPipe explica em detalhes qual dado é enviado e salvo quando você envia um relatório de erros. + Ler a política de privacidade + Próximo stream automaticamente em lista de espera diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a45b48199..cf7b5510a 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -41,7 +41,7 @@ Următorul videoclip Arată videoclipurile care urmează URL nesuportat - Limba dorită a conținutului + Limba dorită a conținutului Video & Audio Aspect Altele diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 12de382ea..3fad6b288 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -30,7 +30,7 @@ Следующее видео URL не поддерживается \"Следующее\" и \"Похожие\" видео - Язык контента по умолчанию + Язык контента по умолчанию Видео и аудио Внешний вид Другое @@ -470,7 +470,7 @@ Удалить историю запросов поиска Удалить историю воспроизведённых потоков Вся история поиска будет удалена. - История поиска удалена + История поиска удалена. 1 элемент удалён. NewPipe — свободное программное обеспечение: вы можете использовать, изучать и улучшать его по своему усмотрению. В частности, вы можете распространять и/или изменять его в соответствии с условиями GNU General Public License, опубликованной Free Software Foundation, либо версии 3, либо (по вашему выбору) любой более поздней версии. @@ -490,6 +490,7 @@ Предел разрешения в мобильной сети Каналы Плейлисты + Видео Дорожки Пользователи Проматывать тишину @@ -502,4 +503,26 @@ Фоновый плеер Плеер в окне + Вид списка + Список + Сетка + Автоматически + + Менять яркость плеера жестом + Жест яркости + Загрузка на внешний накопитель невозможна. Сбросить расположение папки загрузки? + Внешний накопитель недоступен + Вкладки, видимые на главной странице + По умолчанию + Хотите восстановить умолчания? + Ошибка чтения сохранённых вкладок. Используются вкладки по умолчанию + Выбор + Количество подписчиков недоступно + Переключить вид + Выберите вкладку + Новая вкладка + Отписаться + Менять громкость плеера жестом + Жест громкости + diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 0fefdd04e..a7359b0d7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -29,7 +29,7 @@ Prehrať cez Kodi Aplikácia Kore nie je nainštalovaná. Chcete ju nainštalovať? Zobraziť možnosť \"Prehrať cez Kodi\" - Zobrazovať možnosť prehrať video cez mediálne centrum Kodi + Zobrazovať možnosť prehrať video cez multimediálne centrum Kodi Zvuk Predvolený zvukový formát Téma @@ -40,7 +40,7 @@ Ďalšie video Ukázať \'ďalšie\' a \'podobné\' videá URL nie je podporovaná - Preferovaný jazyk obsahu + Preferovaný jazyk obsahu Video & Zvuk Vzhľad Iné @@ -309,10 +309,10 @@ Premenovať Prispieť - Aplikácia NewPipe je vyvíjaná dobrovoľníkmi vo voľnom čase. Ak sa vám aplikácia páči a chceli by ste odmeniť vývojárov, teraz je ten najlepši čas. Podporte vývojárov aby mohli NewPipe zlepšovať a zároveň si pochutnávať na šálke kávy! + Aplikácia NewPipe je vyvíjaná dobrovoľníkmi vo voľnom čase. Ak sa vám aplikácia páči, odmeňte vývojárov aby mohli NewPipe naďalej vylepšovať. Určite ich poteší napríklad šálka dobrej kávy. Daruj Webstránka - Ak chcete získať ďalšie informácie a novinky o NewPipe navštívte naše webové stránky. + "Pre viac informácií a noviniek navštívte webstránku NewPipe." Chcete odstrániť túto položku z histórie vyhľadávania? Chcete odstrániť túto položku z histórie pozretých videí? Ste si istý, že chcete vymazať všetky položky z histórie? @@ -377,7 +377,7 @@ Miniatúra zoznamu skladieb bola zmenená Nemožno odstrániť zoznam skladieb - Bez popisu + Bez titulkov Prispôsobiť Vyplniť @@ -417,14 +417,14 @@ Automaticky vygenerované - Nastavenie titulkov + Titulky Upravte mierku textu titulkov a štýly pozadia. Vyžaduje reštart prehrávača Povoliť službu LeakCanary Monitorovanie pretečenia pamäte môže spôsobiť, že aplikácia nebude reagovať Nahlásiť mimo-cyklické chyby - Vynútenie hlásenia nedodržateľných výnimiek Rx, ktoré sa vyskytnú mimo časového cyklu fragmentu alebo aktivity po zlikvidovaní + Vynútiť hlásenie výnimiek nedoručiteľných Rx mimo časového cyklu fragmentov alebo aktivity po zneškodnení Import/Export \n @@ -449,16 +449,13 @@ \n2. Po výzve sa prihláste do svojho účtu \n3. Sťahovanie by malo začať (to je exportovaný zoznam) \n - "Importovať SoundCloud profil zadaním URL adresy alebo vášho ID: -\n -\nAk nepoznáte ani URL ani ID vašeho profilu, môžete postupovať nasledovne: -\n -\n1. V niektorom prehliadači povoľte režim \"desktop\" (web nie je dostupný pre mobilné zariadenia) -\n2. Prejdite na túto adresu URL: %1$s + "Importovať SoundCloud profil zadaním URL adresy alebo vášho ID: +\n +\n1. Prepnite režim na \"desktop\" (web nie je dostupný pre mobilné zariadenia) +\n2. Prejdite na túto URL adresu: %1$s \n3. Po výzve sa prihláste do svojho účtu -\n4. Skopírujte adresu URL, na ktorú ste boli presmerovaní (to je adresa vášho profilu). -\n" - ID,soundcloud.com/ID +\n4. Skopírujte adresu URL, na ktorú ste boli presmerovaní. " + vašeID, soundcloud.com/vašeid Operácia môže byť náročná na počet prenesených dát. \n @@ -467,7 +464,7 @@ Ovládanie rýchlosti prehrávania Rýchlosť Výška - "Zvoľnenie (môže spôsobovať skreslenie)" + "Spomalenie (môže spôsobovať skreslenie)" Nightcore režim Predvolené Vymazať históriu pozretí @@ -493,4 +490,18 @@ Bez limitu Limitovať rozlíšenie pri použití mobilných dát - + Kanály + Zoznamy skladieb + Skladby + Používatelia + Pretáčať tiché pasáže + Krok + Vynulovať + + Minimalizovať pri prepnutí aplikácie + Akcia pri prepnutí na inú aplikáciu z hlavného prehrávača videa — %s + Nič + Prehrávať na pozadí + Prehrávať v okne + + diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index d2d6d4343..ec2a0bea5 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -29,7 +29,7 @@ Naslednji video Pokaži naslednji video in podobne posnetke Zapis naslova URL ni podprt. - Privzeti jezik vsebine + Privzeti jezik vsebine Video in Zvok Sličica predogleda videa Sličica predogleda videa @@ -91,7 +91,7 @@ Opomba (v angleščini): Dovoljenje za dostop do shrambe je zavrnjeno Samodejno predvajanje - Samodejno predvaja vsebino, če je NewPipe klican iz drugega programa + Predvaja vsebino, če je program zagnan iz drugega programa Pošlji poročilo o napaki Poročilo uporabnika @@ -320,4 +320,5 @@ odpiranje v pojavnem načinu Opusti Preimenuj + Naloži sličice diff --git a/app/src/main/res/values-sq/strings.xml b/app/src/main/res/values-sq/strings.xml index 86d15b020..ec31c4a97 100644 --- a/app/src/main/res/values-sq/strings.xml +++ b/app/src/main/res/values-sq/strings.xml @@ -30,7 +30,7 @@ Shkarko Videoja tjetër Shërbimi - Gjuha e dëshiruar e përmbajtjeve + Gjuha e dëshiruar e përmbajtjeve Aplikacioni për video Sjellja Video & Audio diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 60f99e254..fa3011936 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -28,7 +28,7 @@ Следећи видео УРЛ није подржан Прикажи следећи и слични видео - Подразумевани језик садржаја + Подразумевани језик садржаја Видео и аудио Остало Сличица видео прегледа diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index ed426cc13..ddf3888f5 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -10,10 +10,10 @@ Ladda ner Sök Inställningar - Menade du: %1$s ? + Menade du: %1$s\? Dela med Välj webbläsare - rotering + rotation Använd extern videospelare Några upplösningar kommer INTE ha ljud när det här alternativet är aktiverat Använd extern ljudspelare @@ -57,7 +57,7 @@ Nästa video Visa \'nästkommande\' och \'liknande\' videor Webbadressen stöds inte - Standard innehållsspråk + Standard innehållsspråk Video & Ljud Popup-ruta Utseende @@ -109,7 +109,7 @@ %1$s visningar Prenumerera Prenumererad - Prenumerationen togs bort + Prenumeration avslutad Kunde inte ändra prenumeration Kunde inte uppdatera prenumeration @@ -286,10 +286,10 @@ Bokmärken - Lägga till + Lägg till Använda snabb inexakt sökning - Ladda miniatyrer + Ladda miniatyrbilder Inaktivera för att stoppa alla miniatyrbilder från att ladda och spara på data och minnesanvändning. Ändring av detta kommer att rensa cache-minnet Bild cacheminnet var rensad Tjänst @@ -396,7 +396,7 @@ Vill du ta bort den här spellistan? Spellistan skapades Tillagad i spellistan - "Spellistans miniatyrbild förändrades " + Spellistans miniatyrbild förändrades Kunde inte ta bort spellistan Ingen textning @@ -449,7 +449,7 @@ Uppspelningshastighet Kontroller Tempo - Pitch + Tonhöjd Avlänka (kan orsaka förvrängning) Snabbspola vid frånvaro av ljud Steg diff --git a/app/src/main/res/values-te/strings.xml b/app/src/main/res/values-te/strings.xml index dc14446e7..b566977d5 100644 --- a/app/src/main/res/values-te/strings.xml +++ b/app/src/main/res/values-te/strings.xml @@ -53,7 +53,7 @@ తదుపరి వీడియో మరియు ఇలాంటి వీడియో చిట్కాను అనుబంధించడానికి హోల్డ్ను చూపు Url మద్దతు లేదు - డిఫాల్ట్ భాష + డిఫాల్ట్ భాష ప్లేయర్ ప్రవర్తన వీడియో & ఆడియో diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e7715d545..bffad3ed8 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -11,7 +11,7 @@ İndir Ara Ayarlar - Bunu mu demek istediniz: %1$s ? + Bunu mu demek istediniz: %1$s\? Şununla paylaş Tarayıcı seç döndürme @@ -42,7 +42,7 @@ Sonraki video \'Sonraki\' ve \'benzer\' videoları göster URL desteklenmiyor - Öntanımlı içerik dili + Öntanımlı içerik dili Ses Video ve Ses Görünüm @@ -65,7 +65,7 @@ Web sitesi tümüyle ayrıştırılamadı İçerik kullanılabilir değil GEMA tarafından engellendi - Bu, henüz desteklenmeyen, bir CANLI AKIŞ. + Bu, henüz desteklenmeyen bir CANLI AKIŞ. Herhangi bir akış alınamadı Resim yüklenemedi Uygulama/arayüz çöktü @@ -254,7 +254,7 @@ Geçmiş temizlendi Öge silindi Bu içeriği arama geçmişinden silmek istiyor musunuz? -\"Kuyruğa almak İçin bas\" ipucunu göster +\"Kuyruğa almak için basılı tut\" ipucunu göster Video ayrıntıları sayfasında arka plan veya açılır oynatıcı düğmesine basıldığında ipucu göster Arka plan oynatıcıda kuyruğa eklendi Açılır oynatıcıda kuyruğa eklendi @@ -272,7 +272,7 @@ Abonelik Sayfası Besleme Sayfası Kanal Sayfası - Bir kanal seç + Kanal seç Henüz abone olunan kanal yok Köşk seç @@ -285,7 +285,7 @@ Kaldır Ayrıntılar Ses Ayarları - Kuyruğa Almak İçin Bas + Kuyruğa Almak İçin Basılı Tut Arka Planda Kuyruğa Al Açılır Oynatıcıda Kuyruğa Al Burada Oynatmaya Başla @@ -334,9 +334,9 @@ Akış dosyasını indir. Bilgileri göster - Yer imleri + Yer İmleri - Şuna Ekle + Listeye Ekle Yeniden sıralamak için sürükle @@ -380,7 +380,7 @@ Normal yazı tipi Daha büyük yazı tipi Hata Ayıklama - Yakında burada bir şeyler görünecek ;D + Yakında burada bir şeyler görünecek :) Kendiliğinden Üretilmiş @@ -435,7 +435,7 @@ \n \nDevam etmek istiyor musunuz? Küçük resimleri yükle - Küçük resimlerin hepsinin yüklenmesini engellemek ve bellek ve veri kullanımını azaltmak için devre dışı bırakın. Bunu değiştirmek, hem bellekteki hem de diskteki resim önbelleğini temizler + Küçük resimlerin tümünün yüklenmesini engellemek, bellek ve veri kullanımını azaltmak için devre dışı bırakın. Bunu değiştirmek, hem bellekteki hem de diskteki resim önbelleğini temizler Resim önbelleği temizlendi Önbelleklenmiş üst veriyi temizle Önbelleklenmiş tüm web sayfası verisini kaldır diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 084c85920..1dccd25cf 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -10,7 +10,7 @@ Завантажити Шукати Налаштування - Чи ви мали на увазі: %1$s ? + Чи ви мали на увазі: %1$s\? Поділитись з Оберіть переглядач обертання @@ -44,7 +44,7 @@ Наступний відеозапис Являти \"наступні\" й \"схожі\" відео URL не підтримується - Переважна мова контенту + Переважна мова контенту Відео та Авдіо Зовнішній вигляд Інше @@ -483,8 +483,12 @@ До тлового програвача Зменшити до віконного програвачу -Канали + Канали Плейлисти Стежки Користувачі - + + Вигляд списку + Список + Сiтка + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index a55f01085..b5203fbc0 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -28,14 +28,14 @@ Đường dẫn để lưu trữ âm thanh đã tải xuống Nhập đường dẫn tải xuống cho tệp âm thanh - Tự động phát khi được gọi từ một ứng dụng khác - Tự động phát một video khi NewPipe được gọi từ một ứng dụng khác + Tự động phát + Phát video khi NewPipe được gọi từ một ứng dụng khác Độ phân giải mặc định Độ phân giải popup mặc định Hiển thị độ phân giải cao hơn Chỉ một số thiết bị hỗ trợ chơi các video 2K / 4K Phát với Kodi - Ứng dụng Kore không tìm thấy. Cài đặt Kore? + Không tìm thấy ứng dụng Kore. Cài đặt nó? Hiển thị tùy chọn \"Phát với Kodi\" Hiển thị tùy chọn để phát video qua trung tâm media Kodi Âm thanh @@ -47,7 +47,7 @@ Tối Đen Nhớ kích thước và vị trí bật lên - Nhớ kích thước và vị trí cuối cùng được đặt vào cửa sổ bật lên + Nhớ kích thước và vị trí bật lên cuối cùng Điều khiển cử chỉ trình phát Sử dụng cử chỉ để kiểm soát độ sáng và âm lượng của trình phát Đề xuất tìm kiếm @@ -56,7 +56,7 @@ Tải về Video tiếp theo - Hiển thị các video tiếp theo và tương tự + Hiển thị video \'tiếp theo\' và \'tương tự\' URL không được hỗ trợ Hiển thị Khác @@ -65,7 +65,7 @@ Phát Nội dung Hiển thị nội dung bị hạn chế độ tuổi - Video bị giới hạn về tuổi. Bật chế độ cho phép hiển thị video bị hạn chế về độ tuổi ở trong cài đặt trước. + Video giới hạn độ tuổi người xem. Cho phép các tài liệu đó có thể từ Cài đặt. Trực tiếp Tải xuống Tải xuống @@ -84,20 +84,20 @@ Lỗi Lỗi kết nối mạng Không thể tải tất cả các thumbnails - Không thể giải mã chữ ký URL của video. - Không thể phân tích trang web. - Không thể phân tích trang web hoàn toàn. - Nội dung không có sẵn. - Chặn bởi GEMA. - Không thể thiết lập trình đơn tải xuống. - Đây là một video phát trực tiếp. Chúng chưa được hỗ trợ. - Không thể lấy bất kỳ luồng nào. + Không thể giải mã chữ ký URL video + Không thể phân tích cú pháp trang web + Không thể phân tích cú pháp hoàn toàn trang web + Nội dung không khả dụng + Chặn bởi GEMA + Không thể thiết lập menu tải xuống + Đây là STREAM LIVE, chưa được hỗ trợ. + Không thể lấy bất kỳ luồng nào Không thể tải hình ảnh Ứng dụng / Giao diện người dùng bị lỗi Xin lỗi, điều đó không nên xảy ra. Báo lỗi qua email Xin lỗi, một số lỗi đã xảy ra. - BÁo CÁO + BÁO CÁO Thông tin: Chuyện gì đã xảy ra: Gì: \\nRequest:\\nContent Lang:\\nService:\\nGMT Time:\\nPackage:\\nVersion:\\nOS version: @@ -123,7 +123,7 @@ Thử lại Quyền truy cập vào bộ nhớ đã bị từ chối Sử dụng trình phát cũ - Máy nghe nhạc Mediaframework tích hợp sẵn. + Máy nghe nhạc Mediaframework tích hợp sẵn ngàn @@ -132,7 +132,7 @@ Bắt đầu Dừng - Xem + Chơi Xóa checksum @@ -148,7 +148,7 @@ NewPipe đang tải xuống Chạm để biết chi tiết Vui lòng đợi … - Sao chép vào clipboard. + Sao chép vào clipboard Hãy chọn một thư mục tải về có sẵn. Sự cho phép này là cần thiết để \nMở trong chế độ bật lên @@ -168,17 +168,17 @@ Cộng tác viên Giấy phép Giao diện trực quan nhẹ cho Android. - Xem trên Github + Xem trên GitHub Giấy phép của NewPipe Cho dù bạn có ý tưởng, dịch, thay đổi thiết kế, làm sạch mã hoặc thay đổi mã, sự trợ giúp luôn được hoan nghênh. Càng làm nhiều thì càng tốt! Đọc giấy phép Sự đóng góp Quay - Ngôn ngữ nội dung ưu tiên + Ngôn ngữ nội dung ưu tiên Video & Âm thanh - Bật lên - Lịch sử - Lịch sử + Cửa sổ + Lịch sử & bộ nhớ cache + Lịch sử & bộ nhớ cache Danh sách Không tìm thấy Theo dõi @@ -187,4 +187,276 @@ Không thể thay đổi tình trạng theo dõi Không thể cập nhật tình trạng theo dõi - + Không tìm thấy trình phát luồng nào (bạn có thể cài đặt VLC để phát) + Tải xuống tệp luồng. + Hiển thị thông tin + + main + Đăng ký + Dấu trang + + Có gì mới + + Thêm vào + + Sử dụng tìm kiếm không chính xác nhanh + Tìm kiếm không chính xác cho phép người chơi tìm kiếm vị trí nhanh hơn với độ chính xác giảm + Tải hình thu nhỏ + Vô hiệu hóa để ngăn chặn tất cả các hình thu nhỏ tải và lưu dữ liệu và sử dụng bộ nhớ. Thay đổi điều này sẽ xóa bộ nhớ cache hình ảnh trong bộ nhớ và trên đĩa + Đã xóa bộ nhớ cache hình ảnh + Xóa siêu dữ liệu đã lưu vào bộ nhớ cache + Xóa tất cả dữ liệu trang web được lưu trong bộ nhớ cache + Đã xóa bộ nhớ cache siêu dữ liệu + Tự động phát tiếp theo theo hàng + Tự động thêm một luồng có liên quan khi phát luồng cuối cùng trong hàng đợi không lặp lại + Lịch sử tìm kiếm + Lưu trữ truy vấn tìm kiếm cục bộ + Theo dõi các video đã xem + Tiếp tục lấy tiêu điểm + Tiếp tục phát sau khi bị gián đoạn (ví dụ: cuộc gọi điện thoại) + Hiển thị mẹo \"giữ để nối thêm\" + Hiển thị mẹo khi nhấn nút nền hoặc bật lên trên trang chi tiết video + Quốc gia nội dung mặc định + Dịch vụ + Phát + Hành vi + Gỡ lỗi + Đã xếp hàng đợi trên trình phát nền + Xếp hàng đợi trên trình phát bật lên + Kênh + Danh sách phát + Bản nhạc + Người dùng + Hủy bỏ + Chơi tất cả + Luôn luôn + Chỉ một lần + Tập tin + + Thông báo NewPipe + Thông báo cho nền mới và Trình phát Popup + + [Không xác định] + + Chuyển đổi hướng màn hình + Chuyển sang nền + Chuyển sang Popup + Chuyển sang Main + + Nhập cơ sở dữ liệu + Xuất cơ sở dữ liệu + Sẽ ghi đè lịch sử và đăng ký hiện tại của bạn + Xuất lịch sử, đăng ký và danh sách phát + Xóa lịch sử xem + Xóa lịch sử của các luồng đã phát + Xóa toàn bộ lịch sử xem. + Đã xóa lịch sử xem. + Xóa lịch sử tìm kiếm + Xóa lịch sử của từ khóa tìm kiếm + Xóa toàn bộ lịch sử tìm kiếm. + Đã xóa lịch sử tìm kiếm. + Không thể phát luồng này + Đã xảy ra lỗi trình phát không thể khôi phục + Phục hồi từ lỗi trình phát + Người chơi bên ngoài không hỗ trợ các loại liên kết này + URL không hợp lệ + Không tìm thấy luồng video nào + Không tìm thấy luồng âm thanh nào + Thư mục không hợp lệ + Nguồn tệp / nội dung không hợp lệ + Tệp không tồn tại hoặc không đủ quyền đọc hoặc ghi vào tệp + Tên tệp không được để trống + Đã xảy ra lỗi: %1$s + Không có luồng nào để tải xuống + + Không có gì ở đây Nhưng dế + Kéo để sắp xếp lại + + Không có người đăng ký + + %s người đăng kí + + + Không có lượt xem nào + + %s lượt xem + + + Không có video nào + + %s video + + + Tạo nên + Xóa một + Xóa hết + Bỏ qua + Đổi tên + + Đã xóa 1 mục. + + Tải về + Các ký tự được cho phép trong tên tệp + Ký tự không hợp lệ được thay thế bằng giá trị này + Ký tự thay thế + + Chữ cái và chữ số + Hầu hết các ký tự đặc biệt + + Không có ứng dụng nào được cài đặt để phát tệp này + + Đóng góp + NewPipe được phát triển bởi các tình nguyện viên dành thời gian mang lại cho bạn những trải nghiệm tốt nhất. Hãy trở lại để giúp các nhà phát triển làm cho NewPipe thậm chí còn tốt hơn trong khi thưởng thức một tách cà phê. + Trả lại + Trang mạng + Truy cập trang web NewPipe để biết thêm thông tin và tin tức. + Chính sách bảo mật của NewPipe + Dự án NewPipe rất coi trọng quyền riêng tư của bạn. Do đó, ứng dụng không thu thập bất kỳ dữ liệu nào mà không có sự đồng ý của bạn. +\nChính sách bảo mật của NewPipe giải thích chi tiết dữ liệu nào được gửi và lưu trữ khi bạn gửi báo cáo sự cố. + Đọc chính sách bảo mật + NewPipe là phần mềm miễn phí copyleft: Bạn có thể sử dụng, chia sẻ nghiên cứu và cải thiện nó theo ý muốn của bạn. Cụ thể bạn có thể phân phối lại và / hoặc sửa đổi nó theo các điều khoản của Giấy phép Công cộng GNU như được xuất bản bởi Quỹ Phần mềm Tự do, hoặc phiên bản 3 của Giấy phép, hoặc (tùy chọn của bạn) bất kỳ phiên bản nào sau này. + Lịch sử + Đã tìm kiếm + Đã xem + Lịch sử bị tắt + Lịch sử + Lịch sử trống + Đã xóa lịch sử + Đã xóa mục + Bạn có muốn xóa mục này khỏi lịch sử tìm kiếm không? + Bạn có muốn xóa mục này khỏi lịch sử xem không? + Bạn có chắc chắn muốn xóa tất cả các mục khỏi lịch sử không? + Lần chơi cuối + Hầu hết phát + + Nội dung trang chính + Trang trống + Trang chủ + Trang đăng ký + Trang nguồn cấp dữ liệu + Trang kênh + Chọn kênh + Chưa có kênh nào được đăng ký + Chọn Trang chủ + Xuất xong + Nhập hoàn tất + Không có tệp ZIP hợp lệ + Cảnh báo: Không thể nhập tất cả các tệp. + Thao tác này sẽ ghi đè cài đặt hiện tại của bạn. + Bạn cũng muốn nhập cài đặt? + + Trang chủ + Xu hướng + Mới & hot + Trình phát nền + Trình phát Popup + Tẩy xoá + Chi tiết + Cài đặt âm thanh + Giữ để Enqueue + Phát trên nền + Phát qua cửa sổ + Bắt đầu chơi ở đây + Bắt đầu ở đây trên nền + Bắt đầu ở đây trên Popup + + Mở ngăn kéo + Đóng ngăn + Một cái gì đó sẽ xuất hiện ở đây sớm ;D + + + Hành động \'mở\' được ưu tiên + Hành động mặc định khi mở nội dung — %s + + Trình phát video + Trình phát nền + Trình phát Popup + Luôn luôn hỏi + + Đang nhận thông tin… + Đang tải nội dung được yêu cầu + + Tạo danh sách mới + Xóa danh sách phát + Đổi tên danh sách phát + Tên + Thêm vào danh sách phát + Đặt làm Hình thu nhỏ của danh sách phát + + Đánh dấu trang danh sách phát + Xóa dấu trang + + Bạn có muốn xóa danh sách phát này không? + Đã tạo danh sách phát + Đã thêm vào danh sách phát + Đã thay đổi hình thu nhỏ của danh sách phát + Không thể xóa danh sách phát + + Không có phụ đề + + Phù hợp + Lấp đầy + Thu phóng + + Tự động tạo ra + + Phụ đề + Sửa đổi tỷ lệ văn bản chú thích của người chơi và kiểu nền. Yêu cầu khởi động lại ứng dụng để có hiệu lực + + Bật LeakCanary + Theo dõi rò rỉ bộ nhớ có thể khiến ứng dụng trở nên không phản hồi khi đổ xô đống + + Báo cáo lỗi ngoài vòng đời + Buộc báo cáo ngoại lệ Rx không thể gửi được bên ngoài vòng đời của mảnh hoặc hoạt động sau khi xử lý + + Nhập khẩu/xuất khẩu + Nhập + Nhập từ + Xuất sang + + Đang nhập… + Đang xuất … + + Nhập tệp + Xuất trước + + Không thể nhập đăng ký + Không thể xuất đăng ký + + Nhập đăng ký YouTube bằng cách tải xuống tệp xuất: +\n +\n1. Truy cập URL này: %1$s +\n2. Đăng nhập khi được hỏi +\n3. Quá trình tải xuống sẽ bắt đầu (đó là tệp xuất) + Nhập hồ sơ SoundCloud bằng cách nhập URL hoặc ID của bạn: +\n +\n1. Bật \"chế độ màn hình\" trong trình duyệt web (trang web không khả dụng cho thiết bị di động) +\n2. Truy cập URL này: %1$s +\n3. Đăng nhập khi được hỏi +\n4. Sao chép URL tiểu sử mà bạn đã được chuyển hướng đến. + Hãy nhớ rằng hoạt động này có thể là mạng đắt tiền. +\n +\nBạn có muốn tiếp tục? + + Điều khiển tốc độ phát lại + Speed + Chiều cao + Hủy liên kết (có thể gây méo) + Tua đi nhanh trong khi im lặng + Tiếp theo + Cài lại + + Để tuân thủ Quy định bảo vệ dữ liệu chung của châu Âu (GDPR), chúng tôi sẽ thu hút sự chú ý của bạn đến chính sách bảo mật của NewPipe. Vui lòng đọc kỹ. +\nBạn phải chấp nhận nó để gửi cho chúng tôi báo cáo lỗi. + Chấp nhận + Từ chối + + Không giới hạn + Giới hạn độ phân giải khi sử dụng dữ liệu di động + Giảm thiểu trên công tắc ứng dụng + Hành động khi chuyển sang ứng dụng khác từ trình phát video chính — %s + không ai + Thu nhỏ xuống trình phát nền + Thu nhỏ trình phát bật lên + + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 39dfefe48..55cfad9ef 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -34,7 +34,7 @@ 即将播放 显示下一部和相似的视频 不支援此网址 - 默认内容语言 + 默认内容语言 视频和音频 外观 其他 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index a8e847694..506705536 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -30,7 +30,7 @@ 下一部影片 顯示下一部及相關的影片 不支援此網址 - 預設內容語言 + 預設內容語言 影片及聲音 外觀 其他 @@ -55,7 +55,7 @@ 未能建立下載路徑「%1$s」 已建立下載路徑「%1$s」 - 內容被 GEMA 封鎖。 + 內容被 GEMA 封鎖 按一下搜尋按鈕以開始操作 自動撥放 當其他應用程式要求播放影片時,NewPipe 將會自動播放 @@ -69,9 +69,9 @@ 無法為影片地址的簽署解碼。 無法讀取網站。 無法完全讀取網站。 - 無法設定下載清單。 + 無法設定下載清單 此內容是一個直播串流,所以暫時未能播放。 - 無法取得任何串流。 + 無法取得任何串流 抱歉,這是不應該發生的。 透過電郵彙報問題 抱歉,程式出現了問題。 @@ -114,7 +114,7 @@ NewPipe 正在下載 按一下以查看詳情 請稍候… - 已複製至剪貼板。 + 已複製至剪貼板 請選擇下載資料夾。 在畫中畫模式開啟 @@ -130,7 +130,7 @@ 所有 頻道 - 稍候 + 稍後 無法取得圖片 應用程式或介面出現問題 事件:\\n請求:\\n內容語言:\\n服務:\\nGMT 時間:\\nPackage:\\n版本:\\n作業系統版本: @@ -165,7 +165,7 @@ 最佳解像度 調整大小 - 使用舊的內置 Mediaframework 播放器。 + 使用舊的內置 Mediaframework 播放器 B 關於 NewPipe @@ -179,7 +179,7 @@ 貢獻者 特許 在 Android 上運作自由輕便的 Youtube 前端。 - 檢視我們的 Github + 檢視我們的 GitHub NewPipe 的特許 無論您僅想分享您對 NewPipe 的一些構思,還是願意設計和翻譯程式介面,甚至想幫我們整理或重新編寫原始碼,我們都無任歡迎。貢獻更多,應用程式便會變得更好! 檢閱特許 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8c4466a2e..e8a9e714c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,11 +1,11 @@ - 點播%1$s次 - %1$s發布 - 找不到串流播放器,您要安裝VLC吗? + 點播 %1$s 次 + %1$s 發布 + 找不到串流播放器,您要安裝 VLC 嗎? 安裝 取消 - 用瀏覽器開啟 + 以瀏覽器開啟 分享 下載 搜尋 @@ -20,11 +20,11 @@ 已下載影片的存放路徑 輸入影片下載路徑 預設解析度 - 用Kodi播放 - 顯示用Kodi媒體中心播放影片的選項 + 用 Kodi 播放 + 顯示用 Kodi 媒體中心播放影片的選項 聲音 - 找不到Kore,您要安裝Kore嗎? - 顯示「用Kodi播放」的選項 + 找不到 Kore ,您要安裝 Kore 嗎? + 顯示「用 Kodi 播放」的選項 預設音訊格式 主題 灰暗 @@ -34,11 +34,11 @@ 下一部影片 顯示「下一部」與「相關」的影片 不支援此網址 - 預設內容語言 + 預設內容語言 影片和音訊 外觀 其他 - 在背景播放 + 背景播放中 播放 網路錯誤 @@ -48,37 +48,37 @@ 喜歡 不喜歡 使用 Tor - (實驗性功能)強制使用 Tor 下載(暫時不支援串流影片)。 + (實驗性) 強迫下載流量繞經 Tor 以加強隱私 (暫未支援串流影片)。 音訊下載路徑 已下載音訊的存放路徑 輸入音訊檔案的下載路徑 無法建立下載目錄「%1$s」 已建立下載目錄「%1$s」 -輕觸搜尋按鈕開始使用NewPipe +輕觸搜尋按鈕開始使用 NewPipe 以懸浮視窗開啟 勾選後,部分解析度的影片將沒有聲音 - NewPipe懸浮視窗模式 + NewPipe 懸浮視窗模式 背景播放 自動播放 - NewPipe 被其他應用程式呼叫時播放影片 + 當 NewPipe 被其他應用程式呼叫時播放影片 懸浮視窗預設解析度 顯示更高的解析度 - 只有部分裝置能播2K及4K影片 + 只有部分裝置能播 2K 及 4K 影片 預設影片格式 純黑 記住懸浮視窗大小和位置 - 記住上次懸浮視窗的大小和位置 + 記住上次使用時懸浮視窗的大小和位置 播放器手勢控制 - 使用手勢來控制亮度及播放器的音量 + 使用手勢來控制播放器的亮度及音量 搜尋建議 - 在搜尋時顯示搜尋建議 + 搜尋時顯示搜尋建議 懸浮視窗 - 在懸浮視窗中播放 + 以懸浮視窗播放中 內容 - 顯示受年齡限制的內容 - 此影片具有年齡限制,請先在設定中關閉年齡限制。 + 顯示具有年齡限制的內容 + 有年齡限制的影片。可於設定中選擇允許此種內容。 下載 下載 錯誤回報 @@ -92,7 +92,7 @@ 清除 最佳解析度 - 重新設定大小 + 調整大小 錯誤 無法載入所有縮圖 無法解析影片 URL 簽章 @@ -105,7 +105,7 @@ 無法取得串流 無法載入圖片 應用程式或界面已停止運作 - 抱歉,這不應該發生的。 + 抱歉,這是不該發生的。 使用電子郵件回報錯誤 抱歉,發生了一些問題。 回報 @@ -123,8 +123,8 @@ 音訊 重試 無法存取儲存空間 - 使用舊的播放器 - 舊型內建媒體播放器 + 使用舊式播放器 + 舊型內建 Mediaframework 播放器 @@ -147,7 +147,7 @@ 檔案已存在 錯誤的網址或網路無法使用 NewPipe 下載中 - 輕觸顯示詳細資訊 + 輕觸以顯示詳細資訊 請稍候… 已複製至剪貼簿 請選擇下載資料夾 @@ -167,7 +167,7 @@ 無法更新訂閱 主頁 - 訂閱 + 訂閱清單 新鮮事 @@ -175,7 +175,7 @@ 在本機儲存搜尋紀錄 歷史紀錄與快取 記錄觀看過的影片 - 在取得視窗焦點時繼續播放 + 取得視窗焦點時繼續播放 在干擾結束後繼續播放(例如有來電) 播放器 行為 @@ -222,10 +222,10 @@ 關於 貢獻者 授權條款 - Android 上開放且輕巧的 YouTube 串流應用程式。 + Android 上自由且輕巧的 YouTube 串流播放器。 在 GitHub 上檢視 NewPipe 使用的授權條款 - 不管你有什麼點子,翻譯、設計、程式碼整理,或者程式碼撰寫,我們永遠歡迎你來幫忙。完成的越多,NewPipe 也會更好! + 不管你有什麼點子——翻譯、設計、程式碼整理,或者程式碼撰寫——我們永遠歡迎你來幫忙。完成的越多,NewPipe 也會更好! 閱讀授權條款 貢獻 @@ -238,12 +238,12 @@ 已清除歷史紀錄 項目已刪除 確定要刪除此項搜尋紀錄嗎? -找不到串流播放器(您可以安裝 VLC播放) - 顯示「鎖定到附加」提示 +找不到串流播放器(您可以安裝 VLC 播放) + 顯示「長按以新增」提示 預設內容國家 服務 - 在背景播放器上等候 - 在懸浮視窗播放器上等候 + 已新增至背景播放佇列 + 已新增至懸浮視窗播放佇列 全部播放 總是 僅一次 @@ -257,9 +257,9 @@ 無法播放此串流 發生無法復原的播放器錯誤 - 從播放器錯誤中恢復 - 在背景或是影片詳細資訊頁面上按下浮模按鈕時顯示提示 - 外部播放器不支援這類型的連結 + 正在從播放器錯誤中復原 + 在影片詳細資訊頁按下背景播放或懸浮視窗按鈕時顯示提示 + 外部播放器不支援此類型連結 無效的 URL 找不到影片串流 找不到音訊串流 @@ -270,8 +270,8 @@ 匯出資料庫 將覆蓋您目前的歷史記錄和訂閱 匯出歷史記錄、訂閱和播放清單 - 返回 - 欲了解更多關於 NewPipe 的資訊和新聞,請造訪我們的網站。 + 回饋 + 如欲了解更多有關 NewPipe 的資訊和新聞,請造訪我們的網站。 首頁內容 空白頁面 互動導覽頁面 @@ -279,10 +279,10 @@ 提要頁面 頻道頁面 選擇頻道 - 尚未訂閱頻道 + 尚未訂閱任何頻道 選擇互動導覽 - 匯出完成 - 匯入完成 + 匯出已完成 + 匯入已完成 無有效的 ZIP 檔案 警告:無法匯入所有檔案。 這將覆蓋您目前的設定。 @@ -296,20 +296,20 @@ 移除 詳細資訊 音訊設定 - 在背景佇列 - 在懸浮視窗佇列 - 在此開始播放 - 在背景這裡開始 - 在懸浮視窗這裡開始 + 新增至背景佇列 + 新增至懸浮視窗佇列 + 從這裡開始播放 + 從這裡開始以背景播放 + 從這裡開始以懸浮視窗播放 - 維持在佇列 - NewPipe 由志願者所開發,他們花費了空閒時間將獲得的最佳體驗帶給您。現在是時候回過頭來,讓我們的開發人員可以能夠在享受一杯咖啡的同時,讓 NewPipe 變得更好。 + 長按以新增至佇列 + NewPipe 由志願者所開發,他們耗費時間務求為您帶來最佳體驗。現在是時候回過頭來,讓我們的開發人員能夠在使 NewPipe 更臻完美的同時,享受一杯咖啡。 打開抽屜 關閉抽屜 - 影片播放 - 背景播放 - 懸浮視窗播放 + 影片播放器 + 背景播放器 + 懸浮視窗播放器 總是詢問 正在取得資訊… @@ -321,16 +321,16 @@ 新增至 - 拖曳重新排序 + 拖曳以重新排序 建立 - 刪除一個 + 刪除 全部刪除 退出 - 更改名稱 + 重新命名 - 您是否要從觀看記錄中刪除這個項目嗎? - 您確實要刪除歷史記錄中的所有項目嗎? + 您是否要刪除此項觀看記錄? + 您確定要刪除歷史記錄中的所有項目嗎? 上一次播放 最常播放 @@ -338,23 +338,23 @@ 建立新的播放清單 刪除播放清單 - 重命名播放清單 + 重新命名播放清單 名稱 - 增加至播放清單 + 新增至播放清單 設為播放清單縮圖 - 書簽播放清單 - 移除書簽 + 將播放清單加入書籤 + 移除書籤 您是否要刪除此播放清單? 已建立播放清單 - 加入到播放清單 + 已新增至播放清單 播放清單縮圖已更改 無法刪除播放清單 沒有字幕 - 適合的 + 合適的 填滿 縮放 @@ -363,7 +363,7 @@ 正常字體 加大字體 - 某些東西很快就會出現 ;D + 某些東西即將在此出現 ;D 監測流失 @@ -378,9 +378,9 @@ 強制報告在處理完片段或活動週期外發生的無法傳遞的 Rx 異常 使用粗略但快速的尋找 - 粗略尋找讓播放器更快找到影片的進度位置 - 自動播放隊列中下一部影片 - 在非重複播放佇列中的最後一個串流上開始播放時,自動附上相關串流 + 粗略的尋找能讓播放器以降低的精確度更快找到影片的進度位置 + 自動將下一部影片新增至佇列 + 在非重複播放佇列中最後一個串流開始播放時,自動新增相關串流 同步 檔案 @@ -388,12 +388,12 @@ 無效的目錄 無效的檔案/內容來源 檔案名稱不能留空 - 發生錯誤:%1$s + 發生錯誤: %1$s 匯入/匯出 匯入 - 匯入來自 - 匯出到 + 匯入自 + 匯出至 正在匯入… 正在匯出… @@ -404,75 +404,75 @@ 之前的匯出 - 檔案不存在或沒有足夠的權限讀取或寫入 + 檔案不存在或權限不足以讀取或寫入該檔案 透過下載匯出檔案來匯入您的 YouTube 訂閱: \n -\n1. 轉到此網址:%1$s -\n2. 當被詢問時登入您的帳戶 +\n1. 移至此網址:%1$s +\n2. 當被詢問時登入 \n3. 下載應該開始 ( 這就是匯出的檔案 ) yourID, soundcloud.com/yourid - 請記住,此操作可能會造成網路昂貴花費。 -\n -\n您想繼續嗎? -透過輸入 URL 或您的 ID 來匯入 SoundCloud 檔案: + 請記住,此操作可造成昂貴網路花費。 \n -\n1. 在一些瀏覽器中啟用「桌面模式」(該網站不適用於行動裝置) -\n2. 移至此網址:%1$s -\n3. 詢問時登入到您的帳號 -\n4. 複製您被重新導向到的網址。 +\n您是否希望繼續? +透過輸入 URL 或您的 ID 來匯入 SoundCloud 個人設定檔: +\n +\n1. 在瀏覽器中啟用「桌面模式」(該網站不適用於行動裝置) +\n2. 移至此網址: %1$s +\n3. 當被詢問時登入 +\n4. 複製您被重新導向到的個人設定檔網址。 載入縮圖 - 停用後,NewPipe將不再載入縮圖,減少數據使用與騰空儲存空間,亦會清除記憶體和磁碟上的縮圖快取 + 停用後 NewPipe 將不再載入縮圖,減少數據和儲存空間的用量。改變此選項時將清除記憶體和磁碟上的縮圖快取 已清除圖片快取 - 抹除快取中介資料 + 清除快取中介資料 移除所有網頁的快取資料 已清除中介資料快取 播放速度控制 - 節拍 - 間距 + 節奏 + 音高 解除連結(可能導致失真) Nightcore 預設 偏好的「開啟」動作 - 開起內容時的預設動作 — %s + 開啟內容時的預設動作 — %s 沒有可供下載的串流 字幕 - 調整播放器字幕大小與背景樣式。必須重新啟動應用程式才會生效 + 調整播放器字幕文字大小與背景樣式。必須重新啟動應用程式才會生效 未安裝可播放此檔案的應用程式 清除觀看歷史 刪除播放過的串流歷史 - 刪除全部的觀看歷史。 + 刪除所有觀看歷史。 觀看歷史已刪除。 清除搜尋歷史 刪除搜尋關鍵字的歷史 - 刪除全部的搜尋歷史。 + 刪除所有搜尋歷史。 搜尋歷史已刪除。 已刪除 1 個項目。 - NewPipe 是一個 Copyleft 的自由軟體:您可以隨意使用、研究、分享並改進它。您可以在遵守由自由軟體基金會所發佈的 GNU 通用公共授權條款的狀況下自由地再散佈及/或修改它,授權條款預設使用第三版,但您也可以選擇更新的版本。 - 您是否同時的匯入設定? + NewPipe 是一個 Copyleft 的自由軟體:您可以隨意使用、研究、分享並改進它。在遵守由自由軟體基金會所發佈的 GNU 通用公共授權條款的狀況下,您可以自由地再散佈及/或修改它;授權條款預設使用第三版,但您也可以選擇更新的版本。 + 您是否要同時匯入設定? NewPipe 的隱私政策 - NewPipe 專案非常重視您的隱私。因此,未經您的同意應用程式不會收集任何的資料。 -\nNewPipe 的隱私權政策,詳細說明了當您發送錯誤回報時,什麼資料才會進行傳送及儲存。 + NewPipe 專案非常重視您的隱私。因此,未經您同意此程式不會收集任何資料。 +\nNewPipe 的隱私權政策詳細說明了當您發送錯誤回報時,什麼資料會被傳送及儲存。 閱讀隱私政策 - 為了符合歐洲通用資料保護條例 ( GDPR ) ,我們請您注意 NewPipe 的隱私政策。請您務必仔細閱讀。 + 為配合歐洲通用資料保護條例 ( GDPR ) ,我們在此請您注意 NewPipe 的隱私政策。請務必仔細閱讀。 \n您必須接受它才能向我們發送錯誤報告。 接受 - 下降 + 拒絕 沒有限制 - 當您使用行動網路時限制解析度 - 在應用程式切換時最小化 - 當從主影片播放器切換到其他應用程式時要進行的動作 — %s + 使用行動網路時限制解析度 + 切換應用程式時最小化 + 從主影片播放器切換到其他應用程式時要執行的動作 — %s 最小化為背景播放器 最小化為彈出式播放器 -在靜音時快轉 +靜音時快轉 步進 重設 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 515f1d46f..5741d1b4f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -63,6 +63,8 @@ #000000 #CD5656 #BC211D + #008ea4 + #005a71 #FFFFFF diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index e7af3231e..229c00533 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -12,6 +12,8 @@ 124dp 70dp + 164dp + 92dp 94dp diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 1a56e2d40..be0709b66 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -19,7 +19,8 @@ autoplay_through_intent use_oldplayer - player_gesture_controls + volume_gesture_control + brightness_gesture_control resume_on_audio_focus_gain popup_remember_size_pos_key use_inexact_seek_key @@ -105,6 +106,8 @@ last_orientation_landscape_key + last_resize_mode + debug_pref_screen_key allow_heap_dumping_key @@ -139,7 +142,7 @@ show_hold_to_append en GB - search_language + content_language content_country show_age_restricted_content use_tor @@ -172,6 +175,24 @@ @string/charset_most_special_characters_value + + downloads_max_retry + 3 + + @string/minimize_on_exit_none_description + 1 + 2 + 3 + 4 + 5 + 7 + 10 + 15 + + + cross_network_downloads + + default_download_threads preferred_open_action_key @@ -372,8 +393,8 @@ Andorra Angola Anguilla - Antarctica - Antiguaand Barbuda + Antartica + Antigua and Barbuda Argentina Armenia Aruba @@ -391,9 +412,9 @@ Bermuda Bhutan Bolivia - Bosniaand Herzegovina + Bosnia and Herzegovina Botswana - BouvetIsland + Bouvet Island Brazil British Virgin Islands British Indian Ocean Territory @@ -404,23 +425,23 @@ Cambodia Cameroon Canada - CapeVerde + Cape Verde Cayman Islands Central African Republic Chad Chile China - HongKong, China - Macao,China + Hong Kong (China) + Macao (China) Christmas Island - Cocos(Keeling) Islands + Cocos (Keeling) Islands Colombia Comoros - Congo(Brazzaville) - Congo, (Kinshasa) + Brazzaville (Congo) + Kinshasa (Congo) Cook Islands - CostaRica - Côted\'Ivoire + Costa Rica + Côte d\'Ivoire Croatia Cuba Cyprus @@ -431,8 +452,8 @@ Dominican Republic Ecuador Egypt - ElSalvador - EquatorialGuinea + El Salvador + Equatorial Guinea Eritrea Estonia Ethiopia @@ -461,8 +482,8 @@ Guinea-Bissau Guyana Haiti - Heardand Mcdonald Islands - HolySee (Vatican City State) + Heard and McDonald Islands + Holy See (Vatican City State) Honduras Hungary Iceland @@ -471,7 +492,7 @@ Iran Iraq Ireland - Isleof Man + Isle of Man Israel Italy Jamaica @@ -481,8 +502,8 @@ Kazakhstan Kenya Kiribati - Korea(North) - Korea(South) + North Korea + South Korea Kuwait Kyrgyzstan Lao @@ -501,7 +522,7 @@ Maldives Mali Malta - MarshallIslands + Marshall Islands Martinique Mauritania Mauritius @@ -533,7 +554,7 @@ Oman Pakistan Palau - Palestinian Territory + Palestine Panama Papua New Guinea Paraguay @@ -542,71 +563,71 @@ Pitcairn Poland Portugal - PuertoRico + Puerto Rico Qatar Réunion Romania Russian Federation Rwanda - Saint-Barthélemy + Saint Barthélemy Saint Helena - Saint KittsandNevis - SaintLucia - Saint-Martin(Frenchpart) - SaintPierreandMiquelon - Saint Vincentand Grenadines + Saint Kitts and Nevis + Saint Lucia + Saint Martin + Saint Pierre and Miquelon + Saint Vincent and Grenadines Samoa San Marino - Sao Tomeand Principe - SaudiArabia + Sao Tome and Principe + Saudi Arabia Senegal Serbia Seychelles - SierraLeone + Sierra Leone Singapore Slovakia Slovenia - SolomonIslands + Solomon Islands Somalia - SouthAfrica - South Georgiaandthe South Sandwich Islands + South Africa + South Georgia and South Sandwich Islands South Sudan Spain Sri Lanka Sudan Suriname - Svalbardand Jan Mayen Islands + Svalbard and Jan Mayen Islands Swaziland Sweden Switzerland - Syrian ArabRepublic(Syria) - Taiwan, Republicof China + Syrian Arab Republic (Syria) + Taiwan Tajikistan Tanzania Thailand - Timor-Leste + Timor Leste Togo Tokelau Tonga - Trinidadand Tobago + Trinidad and Tobago Tunisia Turkey Turkmenistan - Turksand Caicos Islands + Turks and Caicos Islands Tuvalu Uganda Ukraine United Arab Emirates United Kingdom - USA + United States Minor Outlying Islands Uruguay Uzbekistan Vanuatu - Venezuela (BolivarianRepublic) - VietNam - Virgin Islands, - Wallisand Futuna Islands + Venezuela (Bolivarian Republic) + Vietnam + Virgin Islands + Wallis and Futuna Islands Western Sahara Yemen Zambia @@ -879,5 +900,18 @@ 144p + list_view_mode + auto - \ No newline at end of file + + auto + list + grid + + + @string/auto + @string/list + @string/grid + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd00ddce1..12a5d8ca7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,7 +5,7 @@ %1$s views Published on %1$s No stream player found. Do you want to install VLC? - No stream player found (you can install VLC to play it) + No stream player found (you can install VLC to play it). Install Cancel https://f-droid.org/repository/browse/?fdfilter=vlc&fdid=org.videolan.vlc @@ -13,28 +13,29 @@ Open in popup mode Share Download - Download stream file. + Download stream file Search Settings - Did you mean: %1$s ? + Did you mean: %1$s\? Share with Choose browser rotation Use external video player - Some resolutions will NOT have audio when this option is enabled + Removes audio at SOME resolutions Use external audio player NewPipe popup mode RSS Subscribe Subscribed + Unsubscribe Channel unsubscribed - Unable to change subscription - Unable to update subscription + Could not change subscription + Could not update subscription Show info Main Subscriptions - Bookmarks + Bookmarked Playlists New Tab Choose Tab @@ -48,8 +49,8 @@ Path to store downloaded videos in Enter download path for videos - Audio download path - Path to store downloaded audio in + Audio download folder + Downloaded audio is stored here Enter download path for audio files Autoplay @@ -75,7 +76,7 @@ Use fast inexact seek Inexact seek allows the player to seek to positions faster with reduced precision Load thumbnails - Disable to stop all thumbnails from loading and save on data and memory usage. Changing this will clear both in-memory and on-disk image cache. + When off no thumbnails load, saving data and memory usage. Changes clear both in-memory and on-disk image cache. Show comments Disable to stop showing comments Image cache wiped @@ -84,8 +85,10 @@ Metadata cache wiped Auto-queue next stream Auto-append a related stream when playing the last stream in a non-repeating queue. - Player gesture controls - Use gestures to control the brightness and volume of the player + Volume gesture control + Use gestures to control the volume of the player + Brightness gesture control + Use gestures to control the brightness of the player Search suggestions Show suggestions when searching Search history @@ -95,18 +98,19 @@ Resume on focus gain Continue playing after interruptions (e.g. phone calls) Download - Next video - Show \'next\' and \'similar\' videos - Show \"hold to append\" tip + Up next + Autoplay + Show \'Next\' and \'Similar\' videos + Show \"Hold to append\" tip Show tip when background or popup button is pressed on video details page - URL not supported + Unsupported URL Default content country Service - Default content language + Default content language Player Behavior - Video & Audio - History & Cache + Video & audio + History & cache Popup Appearance Other @@ -118,8 +122,8 @@ https://www.c3s.cc/ Play Content - Show age restricted content - Age Restricted Video. Allowing such material is possible from Settings. + Age restricted content + Show age Restricted Video. Allowing such material is possible from \"Settings\". live LIVE Downloads @@ -143,6 +147,7 @@ Resizing Best resolution Undo + File deleted Play All Always Just Once @@ -150,7 +155,7 @@ newpipe NewPipe Notification - Notifications for NewPipe Background and Popup Players + Notifications for NewPipe background and popup players [Unknown] @@ -161,29 +166,29 @@ Import database Export database - Will override your current history and subscriptions - Export history, subscriptions and playlists. + Overrides your current history and subscriptions + Export history, subscriptions and playlists Clear watch history - Deletes the history of played streams. - Delete whole watch history. + Deletes the history of played streams + Delete entire watch history? Watch history deleted. Clear search history - Deletes history of search keywords. - Delete whole search history. + Deletes history of search keywords + Delete entire search history? Search history deleted. Error - External storage not available. - Download to external SD Card is not possible yet. Should the download place be reset? + External storage unavailable + Download to external SD card is not possible yet. Reset download folder location? Network error Could not load all thumbnails Could not decrypt video URL signature Could not parse website Could not parse website completely - Content not available + Content unavailable Blocked by GEMA Could not set up download menu - This is a LIVE STREAM, which is not yet supported. + Live streams are not supported yet Could not get any stream Could not load image App/UI crashed @@ -194,10 +199,10 @@ Invalid URL No video streams found No audio streams found - Invalid directory - Invalid file/content source - File doesn\'t exist or insufficient permission to read or write to it - File name cannot be empty + No such folder + No such file/content source + The file doesn\'t exist or permission to read or write to it is lacking + Filename cannot be empty An error occurred: %1$s No streams available to download Using default tabs, error while reading saved tabs @@ -230,7 +235,7 @@ No results @string/no_videos @string/no_comments - Nothing Here But Crickets + Nothing here but crickets Drag to reorder Cannot create download directory \'%1$s\' @@ -240,8 +245,6 @@ Audio Retry Storage access permission denied - Use old player - Old built-in Mediaframework player K M @@ -293,7 +296,7 @@ Filename Threads Error - Server unsupported + Unsupported server File already exists Malformed URL or Internet not available NewPipe Downloading @@ -308,8 +311,8 @@ MD5 SHA-1 reCAPTCHA - reCAPTCHA Challenge - reCAPTCHA Challenge requested + reCAPTCHA challenge + reCAPTCHA challenge requested @@ -354,7 +357,7 @@ https://newpipe.schabi.org/legal/privacy/ Read privacy policy NewPipe\'s License - NewPipe is copyleft libre software: You can use, study share and improve it at your will. Specifically you can redistribute and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + NewPipe is copyleft libre software: You can use, study share and improve it at will. Specifically you can redistribute and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Read license @@ -362,7 +365,7 @@ History Searched Watched - History is disabled + History is turned off History The history is empty History cleared @@ -383,10 +386,10 @@ Feed Page Channel Page Select a channel - No channel subscribed yet + No channel subscriptions yet Select a kiosk - Export complete - Import complete + Exported + Imported No valid ZIP file Warning: Could not import all files. This will override your current setup. @@ -401,17 +404,17 @@ %1$s/%2$s - Background Player - Popup Player + Background player + Popup player Remove Details Audio Settings - Hold To Enqueue - Enqueue on Background - Enqueue on Popup - Start Playing Here - Start Here on Background - Start Here on Popup + Hold To enqueue + Enqueue when backgrounded + Enqueue on new popup + Start playing here + Start here when backgrounded + Start here on new popup Open Drawer @@ -435,9 +438,9 @@ "Loading requested content" - Create New Playlist - Delete Playlist - Rename Playlist + New Playlist + Delete + Rename Name Add To Playlist Set as Playlist Thumbnail @@ -445,11 +448,11 @@ Bookmark Playlist Remove Bookmark - Do you want to delete this playlist? + Delete this playlist? Playlist created - Added to playlist - Playlist thumbnail changed - Could not delete playlist + Playlisted + Playlist thumbnail changed. + Could not delete playlist. No Captions @@ -468,11 +471,11 @@ Enable LeakCanary Memory leak monitoring may cause the app to become unresponsive when heap dumping - Report Out-of-lifecycle Errors + Report out-of-lifecycle errors Force reporting of undeliverable Rx exceptions outside of fragment or activity lifecycle after disposal - Import/Export + Import/export Import Import from Export to @@ -523,10 +526,61 @@ - Minimize on application switch - Action when switching to other application from main video player — %s + Minimize on app switch + Action when switching to other app from main video player — %s None Minimize to background player Minimize to popup player + List view mode + List + Grid + Auto + Switch View + + + Finished + In queue + + paused + queued + post-processing + + Queue + + Action denied by the system + + + Download failed + Download finished + %s downloads finished + + + Generate unique name + Overwrite + A downloaded file with this name already exists + There is a download in progress with this name + + + Show error + Code + The file can not be created + The destination folder can not be created + Permission denied by the system + Secure connection failed + Can not found the server + 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 + + Clear finished downloads + You have %s pending downloads, goto Downloads to continue + Stop + Maximum retry + Maximum number of attempts before canceling the download + Pause on switching to mobile data + Not all downloads can be suspended, in those cases, will be restarted diff --git a/app/src/main/res/xml/appearance_settings.xml b/app/src/main/res/xml/appearance_settings.xml index 1f711b510..437736ab0 100644 --- a/app/src/main/res/xml/appearance_settings.xml +++ b/app/src/main/res/xml/appearance_settings.xml @@ -22,6 +22,14 @@ android:title="@string/show_hold_to_append_title" android:summary="@string/show_hold_to_append_summary"/> + + - + android:title="@string/content_language_title"/> - - + + + + diff --git a/app/src/main/res/xml/video_audio_settings.xml b/app/src/main/res/xml/video_audio_settings.xml index a547ffaf2..f4492d33d 100644 --- a/app/src/main/res/xml/video_audio_settings.xml +++ b/app/src/main/res/xml/video_audio_settings.xml @@ -64,12 +64,6 @@ android:key="@string/use_external_audio_player_key" android:title="@string/use_external_audio_player_title"/> - - + + + android:key="@string/volume_gesture_control_key" + android:summary="@string/volume_gesture_control_summary" + android:title="@string/volume_gesture_control_title"/> + + missions; - - @org.junit.Before - public void setUp() throws Exception { - downloadDataSource = mock(DownloadDataSource.class); - missions = new ArrayList<>(); - for(int i = 0; i < 50; ++i){ - missions.add(generateFinishedDownloadMission()); - } - when(downloadDataSource.loadMissions()).thenReturn(new ArrayList<>(missions)); - downloadManager = new DownloadManagerImpl(new ArrayList<>(), downloadDataSource); - } - - @Test(expected = NullPointerException.class) - public void testConstructorWithNullAsDownloadDataSource() { - new DownloadManagerImpl(new ArrayList<>(), null); - } - - - private static DownloadMission generateFinishedDownloadMission() throws IOException { - File file = File.createTempFile("newpipetest", ".mp4"); - file.deleteOnExit(); - RandomAccessFile randomAccessFile = new RandomAccessFile(file, "rw"); - randomAccessFile.setLength(1000); - randomAccessFile.close(); - DownloadMission downloadMission = new DownloadMission(file.getName(), - "http://google.com/?q=how+to+google", file.getParent()); - downloadMission.blocks = 1000; - downloadMission.done = 1000; - downloadMission.finished = true; - return spy(downloadMission); - } - - private static void assertMissionEquals(String message, DownloadMission expected, DownloadMission actual) { - if(expected == actual) return; - assertEquals(message + ": Name", expected.name, actual.name); - assertEquals(message + ": Location", expected.location, actual.location); - assertEquals(message + ": Url", expected.url, actual.url); - } - - @Test - public void testThatMissionsAreLoaded() throws IOException { - ArrayList missions = new ArrayList<>(); - long millis = System.currentTimeMillis(); - for(int i = 0; i < 50; ++i){ - DownloadMission mission = generateFinishedDownloadMission(); - mission.timestamp = millis - i; // reverse order by timestamp - missions.add(mission); - } - - downloadDataSource = mock(DownloadDataSource.class); - when(downloadDataSource.loadMissions()).thenReturn(new ArrayList<>(missions)); - downloadManager = new DownloadManagerImpl(new ArrayList<>(), downloadDataSource); - verify(downloadDataSource, times(1)).loadMissions(); - - assertEquals(50, downloadManager.getCount()); - - for(int i = 0; i < 50; ++i) { - assertMissionEquals("mission " + i, missions.get(50 - 1 - i), downloadManager.getMission(i)); - } - } - - @Ignore - @Test - public void startMission() throws Exception { - DownloadMission mission = missions.get(0); - mission = spy(mission); - missions.set(0, mission); - String url = "https://github.com/favicon.ico"; - // create a temp file and delete it so we have a temp directory - File tempFile = File.createTempFile("favicon",".ico"); - String name = tempFile.getName(); - String location = tempFile.getParent(); - assertTrue(tempFile.delete()); - int id = downloadManager.startMission(url, location, name, true, 10); - } - - @Test - public void resumeMission() { - DownloadMission mission = missions.get(0); - mission.running = true; - verify(mission, never()).start(); - downloadManager.resumeMission(0); - verify(mission, never()).start(); - mission.running = false; - downloadManager.resumeMission(0); - verify(mission, times(1)).start(); - } - - @Test - public void pauseMission() { - DownloadMission mission = missions.get(0); - mission.running = false; - downloadManager.pauseMission(0); - verify(mission, never()).pause(); - mission.running = true; - downloadManager.pauseMission(0); - verify(mission, times(1)).pause(); - } - - @Test - public void deleteMission() { - DownloadMission mission = missions.get(0); - assertEquals(mission, downloadManager.getMission(0)); - downloadManager.deleteMission(0); - verify(mission, times(1)).delete(); - assertNotEquals(mission, downloadManager.getMission(0)); - assertEquals(49, downloadManager.getCount()); - } - - @Test(expected = RuntimeException.class) - public void getMissionWithNegativeIndex() { - downloadManager.getMission(-1); - } - - @Test - public void getMission() { - assertSame(missions.get(0), downloadManager.getMission(0)); - assertSame(missions.get(1), downloadManager.getMission(1)); - } - - @Test - public void sortByTimestamp() { - ArrayList downloadMissions = new ArrayList<>(); - DownloadMission mission = new DownloadMission(); - mission.timestamp = 0; - - DownloadMission mission1 = new DownloadMission(); - mission1.timestamp = Integer.MAX_VALUE + 1L; - - DownloadMission mission2 = new DownloadMission(); - mission2.timestamp = 2L * Integer.MAX_VALUE ; - - DownloadMission mission3 = new DownloadMission(); - mission3.timestamp = 2L * Integer.MAX_VALUE + 5L; - - - downloadMissions.add(mission3); - downloadMissions.add(mission1); - downloadMissions.add(mission2); - downloadMissions.add(mission); - - - DownloadManagerImpl.sortByTimestamp(downloadMissions); - - assertEquals(mission, downloadMissions.get(0)); - assertEquals(mission1, downloadMissions.get(1)); - assertEquals(mission2, downloadMissions.get(2)); - assertEquals(mission3, downloadMissions.get(3)); - } - -} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 20c8a0dfc..a95f6dcc0 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:3.1.4' + classpath 'com.android.tools.build:gradle:3.2.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -16,8 +16,8 @@ buildscript { allprojects { repositories { jcenter() - maven { url 'https://jitpack.io' } google() - maven { url 'https://clojars.org/repo' } + maven { url "https://jitpack.io" } + maven { url "https://clojars.org/repo" } } } diff --git a/fastlane/metadata/android/en-US/changelogs/69.txt b/fastlane/metadata/android/en-US/changelogs/69.txt new file mode 100644 index 000000000..c8262d1b0 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/69.txt @@ -0,0 +1,19 @@ +### New +- Long-tap delete and share in subscriptions #1516 +- Tablet UI and grid list layout #1617 + +### Improvements +- store and reload the last used aspect ratio #1748 +- Enable linear layout in Downloads activity with full video names #1771 +- Delete and share subscriptions directly from within the subscriptions tab #1516 +- Enqueuing now triggers video playing if the play queue has already ended #1783 +- Separate settings for volume and brightness gestures #1644 +- Add support for Localization #1792 + +### Fixes +- Fix time parsing for . format, so NewPipe can be used in Finland +- Fix subscription count +- Add foreground service permission for API 28+ devices #1830 + +### Known Bugs +- Playback state can not be saved on Android P diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_1.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_2.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_3.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_4.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_5.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png new file mode 100644 index 000000000..c1f4599c2 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_7.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_7.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_8.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_8.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_9.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png similarity index 100% rename from fastlane/metadata/android/en-US/images/phoneScreenshots/shot_9.png rename to fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png index 6e11014a8..10897c0eb 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_6.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_6.png deleted file mode 100644 index 10c70b54d..000000000 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot_6.png and /dev/null differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png new file mode 100644 index 000000000..91af82879 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png differ diff --git a/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png b/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png new file mode 100644 index 000000000..e362a1975 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png differ