diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 000000000..f61e320c9 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,64 @@ +NewPipe contribution guidelines +=============================== + +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. + +## 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. +* 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 an issue or implement a feature yourself. PRs welcome! +* We use English for development. Issues in other languages will be closed and ignored. +* Please only add *one* issue at a time. Do not put multiple issues into one thread. +* Follow the template! Issues or feature requests not matching the template 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 us 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 is translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). You can log in there +with your GitHub account. +* If the language you want to translate is not on Weblate, you can add it: see [How to add a new language](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-add-a-new-language-to-NewPipe) in the wiki. + +## Code contribution + +* Stick to NewPipe's style conventions: follow [checkStyle](https://github.com/checkstyle/checkstyle). It will run each time you build the project. +* 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 with a meaningful name, not on the master neither dev branch. This is commonly known as *feature branch workflow*. You + may then send your changes as a pull request (PR) on GitHub. +* 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 rebase the dev branch manually and resolve the problems on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). 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. +* Send PR that only cover one specific issue/solution/bug. Do not send PRs that are huge and consists of multiple + independent solutions. + +## Communication + +* 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@newpipe.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 or IRC! diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..2c79d62cd --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +liberapay: TeamNewPipe diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..655a1b96c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Create a bug report to help us improve +labels: bug +assignees: '' + +--- + +# We have fixed the decrpytion exception and a new release is on its wait. Please do not report the bug, that no videos can be played. +If you are about to report something else, please remove this and the above line. Thank you :) + + + + +### Version + +- + +### Steps to reproduce the bug + + + + +### Expected behavior + + +### Actual behaviour + + +### Screenshots/Screen recordings + + +### Logs + + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..90134a204 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,39 @@ +--- +name: Feature request +about: Suggest an idea for this project +labels: enhancement +assignees: '' + +--- + + + + +#### Describe the feature you want + + + + +#### Is your feature request related to a problem? Please describe it + + + + +#### Additional context + + + + +#### How will you/everyone benefit from this feature? + + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..f12eb2fe8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +#### What is it? +- [ ] Bug fix (user facing) +- [ ] Feature (user facing) +- [ ] Code base improvement (dev facing) +- [ ] Meta improvement to the project (dev facing) + +#### Description of the changes in your PR + +- record videos +- create clones +- take over the world + +#### Fixes the following issue(s) + +- + +#### Relies on the following changes + +- + +#### Testing apk + +debug.zip + +#### Agreement +- [ ] I carefully read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md) and agree to them. diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e69de29bb diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..94a9ed024 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program 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. + + This program 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 this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 000000000..7827edd23 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,226 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' +apply plugin: 'checkstyle' + +android { + compileSdkVersion 29 + buildToolsVersion '29.0.3' + + defaultConfig { + applicationId 'org.schabi.newpipelegacy' + resValue "string", "app_name", "NewPipe Legacy" + minSdkVersion 16 + targetSdkVersion 29 + versionCode 90 + versionName "0.19.8" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + + javaCompileOptions { + annotationProcessorOptions { + arguments = ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + } + + buildTypes { + debug { + multiDexEnabled true + debuggable true + + // suffix the app id and the app name with git branch name + def workingBranch = getGitWorkingBranch() + def normalizedWorkingBranch = workingBranch.replaceAll("[^A-Za-z]+", "").toLowerCase() + if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") { + // default values when branch name could not be determined or is master or dev + applicationIdSuffix ".debug" + resValue "string", "app_name", "NewPipe Legacy Debug" + } else { + applicationIdSuffix ".debug." + normalizedWorkingBranch + resValue "string", "app_name", "NewPipe Legacy " + workingBranch + archivesBaseName = 'NewPipe_' + normalizedWorkingBranch + } + } + + // Keep the release build type at the end of the list to override 'archivesBaseName' of + // debug build. This seems to be a Gradle bug, therefore + // TODO: update Gradle version + release { + minifyEnabled true + shrinkResources false // disabled to fix F-Droid's reproducible build + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + archivesBaseName = 'app' + } + } + + lintOptions { + checkReleaseBuilds false + // Or, if you prefer, you can continue to check for errors in release builds, + // but continue the build even when errors are found: + abortOnError false + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + encoding 'utf-8' + } + + // Required and used only by groupie + androidExtensions { + experimental = true + } + + sourceSets { + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) + } +} + +ext { + icepickVersion = '3.2.0' + checkstyleVersion = '8.32' + stethoVersion = '1.5.1' + leakCanaryVersion = '2.2' + exoPlayerVersion = '2.11.6' + androidxLifecycleVersion = '2.2.0' + androidxRoomVersion = '2.2.5' + groupieVersion = '2.8.0' + markwonVersion = '4.3.1' +} + +configurations { + checkstyle + ktlint +} + +checkstyle { + configFile rootProject.file('checkstyle.xml') + ignoreFailures false + showViolations true + toolVersion = checkstyleVersion +} + +task runCheckstyle(type: Checkstyle) { + source 'src' + include '**/*.java' + exclude '**/gen/**' + exclude '**/R.java' + exclude '**/BuildConfig.java' + exclude 'main/java/us/shandian/giga/**' + + classpath = configurations.checkstyle + + showViolations true + + reports { + xml.enabled true + html.enabled true + } +} + +task runKtlint(type: JavaExec) { + main = "com.pinterest.ktlint.Main" + classpath = configurations.ktlint + args "src/**/*.kt" +} + +task formatKtlint(type: JavaExec) { + main = "com.pinterest.ktlint.Main" + classpath = configurations.ktlint + args "-F", "src/**/*.kt" +} + +afterEvaluate { + preDebugBuild.dependsOn runCheckstyle, runKtlint +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + implementation "frankiesardo:icepick:${icepickVersion}" + kapt "frankiesardo:icepick-processor:${icepickVersion}" + + checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" + ktlint "com.pinterest:ktlint:0.35.0" + + debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" + debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" + + debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}" + implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" + + debugImplementation "androidx.multidex:multidex:2.0.1" + + testImplementation 'junit:junit:4.13' + testImplementation 'org.mockito:mockito-core:3.3.3' + + androidTestImplementation "androidx.test.ext:junit:1.1.1" + androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}" + androidTestImplementation "androidx.test.espresso:espresso-core:3.2.0", { + exclude module: 'support-annotations' + } + + implementation 'com.github.TeamNewPipe:NewPipeExtractor:5ac80624a40f4c600ae493e66881b5bf008f0ddb' + + implementation "com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" + implementation "org.jsoup:jsoup:1.13.1" + + implementation "com.squareup.okhttp3:okhttp:3.12.11" + + implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" + + implementation "com.google.android.material:material:1.1.0" + + implementation "androidx.appcompat:appcompat:1.1.0" + implementation "androidx.preference:preference:1.1.1" + implementation "androidx.recyclerview:recyclerview:1.1.0" + implementation "androidx.cardview:cardview:1.0.0" + implementation "androidx.constraintlayout:constraintlayout:1.1.3" + + implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" + implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" + implementation "androidx.lifecycle:lifecycle-extensions:${androidxLifecycleVersion}" + + implementation "androidx.room:room-runtime:${androidxRoomVersion}" + implementation "androidx.room:room-rxjava2:${androidxRoomVersion}" + kapt "androidx.room:room-compiler:${androidxRoomVersion}" + + implementation "com.xwray:groupie:${groupieVersion}" + implementation "com.xwray:groupie-kotlin-android-extensions:${groupieVersion}" + + implementation "de.hdodenhof:circleimageview:3.1.0" + implementation "com.nostra13.universalimageloader:universal-image-loader:1.9.5" + + implementation "io.noties.markwon:core:${markwonVersion}" + implementation "io.noties.markwon:linkify:${markwonVersion}" + + implementation "com.nononsenseapps:filepicker:4.2.1" + + implementation "ch.acra:acra-core:5.5.0" + + implementation "io.reactivex.rxjava2:rxjava:2.2.19" + implementation "io.reactivex.rxjava2:rxandroid:2.1.1" + implementation "com.jakewharton.rxbinding2:rxbinding:2.2.0" + + implementation "org.ocpsoft.prettytime:prettytime:4.0.5.Final" +} + +static String getGitWorkingBranch() { + try { + def gitProcess = "git rev-parse --abbrev-ref HEAD".execute() + gitProcess.waitFor() + if (gitProcess.exitValue() == 0) { + return gitProcess.text.trim() + } else { + // not a git repository + return "" + } + } catch (IOException ignored) { + // git was not found + return "" + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 000000000..53a9ecd5a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,53 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /home/the-scrabi/bin/Android/Sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +-dontobfuscate +-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } +-keep class org.ocpsoft.prettytime.i18n.** { *; } + +-keep class org.mozilla.javascript.** { *; } + +-keep class org.mozilla.classfile.ClassFileWriter +-keep class com.google.android.exoplayer2.** { *; } + +-dontwarn org.mozilla.javascript.tools.** +-dontwarn android.arch.util.paging.CountedDataSource +-dontwarn android.arch.persistence.room.paging.LimitOffsetDataSource + + +# Rules for icepick. Copy paste from https://github.com/frankiesardo/icepick +-dontwarn icepick.** +-keep class icepick.** { *; } +-keep class **$$Icepick { *; } +-keepclasseswithmembernames class * { + @icepick.* ; +} +-keepnames class * { @icepick.State *;} + +# Rules for OkHttp. Copy paste from https://github.com/square/okhttp +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + !static !transient ; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); +} diff --git a/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/2.json b/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/2.json new file mode 100644 index 000000000..2532e330e --- /dev/null +++ b/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/2.json @@ -0,0 +1,479 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b7856223e2595ddf20a3ce6243ce9527", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressTime", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"b7856223e2595ddf20a3ce6243ce9527\")" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/3.json b/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/3.json new file mode 100644 index 000000000..313c3e27c --- /dev/null +++ b/app/schemas/org.schabi.newpipelegacy.database.AppDatabase/3.json @@ -0,0 +1,707 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "9f825b1ee281480bedd38b971feac327", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressTime", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "createSql": "CREATE INDEX `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "group_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "createSql": "CREATE INDEX `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9f825b1ee281480bedd38b971feac327')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/schabi/newpipelegacy/database/AppDatabaseTest.kt b/app/src/androidTest/java/org/schabi/newpipelegacy/database/AppDatabaseTest.kt new file mode 100644 index 000000000..442257fa7 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipelegacy/database/AppDatabaseTest.kt @@ -0,0 +1,119 @@ +package org.schabi.newpipelegacy.database + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase +import androidx.room.Room +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.schabi.newpipe.extractor.stream.StreamType + +@RunWith(AndroidJUnit4::class) +class AppDatabaseTest { + companion object { + private const val DEFAULT_SERVICE_ID = 0 + private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4" + private const val DEFAULT_TITLE = "Test Title" + private val DEFAULT_TYPE = StreamType.VIDEO_STREAM + private const val DEFAULT_DURATION = 480L + private const val DEFAULT_UPLOADER_NAME = "Uploader Test" + private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" + + private const val DEFAULT_SECOND_SERVICE_ID = 0 + private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc" + } + + @get:Rule + val testHelper = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), + AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()) + + @Test + fun migrateDatabaseFrom2to3() { + val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2) + + databaseInV2.run { + insert("streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { + // put("uid", null) + put("service_id", DEFAULT_SERVICE_ID) + put("url", DEFAULT_URL) + put("title", DEFAULT_TITLE) + put("stream_type", DEFAULT_TYPE.name) + put("duration", DEFAULT_DURATION) + put("uploader", DEFAULT_UPLOADER_NAME) + put("thumbnail_url", DEFAULT_THUMBNAIL) + }) + insert("streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { + // put("uid", null) + put("service_id", DEFAULT_SECOND_SERVICE_ID) + put("url", DEFAULT_SECOND_URL) + // put("title", null) + // put("stream_type", null) + // put("duration", null) + // put("uploader", null) + // put("thumbnail_url", null) + }) + insert("streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { + // put("uid", null) + put("service_id", DEFAULT_SERVICE_ID) + // put("url", null) + // put("title", null) + // put("stream_type", null) + // put("duration", null) + // put("uploader", null) + // put("thumbnail_url", null) + }) + close() + } + + testHelper.runMigrationsAndValidate(AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, + true, Migrations.MIGRATION_2_3) + + val migratedDatabaseV3 = getMigratedDatabase() + val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() + + // Only expect 2, the one with the null url will be ignored + assertEquals(2, listFromDB.size) + + val streamFromMigratedDatabase = listFromDB[0] + assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId) + assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url) + assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title) + assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType) + assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration) + assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader) + assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl) + assertNull(streamFromMigratedDatabase.viewCount) + assertNull(streamFromMigratedDatabase.textualUploadDate) + assertNull(streamFromMigratedDatabase.uploadDate) + assertNull(streamFromMigratedDatabase.isUploadDateApproximation) + + val secondStreamFromMigratedDatabase = listFromDB[1] + assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId) + assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url) + assertEquals("", secondStreamFromMigratedDatabase.title) + // Should fallback to VIDEO_STREAM + assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType) + assertEquals(0, secondStreamFromMigratedDatabase.duration) + assertEquals("", secondStreamFromMigratedDatabase.uploader) + assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl) + assertNull(secondStreamFromMigratedDatabase.viewCount) + assertNull(secondStreamFromMigratedDatabase.textualUploadDate) + assertNull(secondStreamFromMigratedDatabase.uploadDate) + assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation) + } + + private fun getMigratedDatabase(): AppDatabase { + val database: AppDatabase = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), + AppDatabase::class.java, AppDatabase.DATABASE_NAME) + .build() + testHelper.closeWhenFinished(database) + return database + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipelegacy/report/ErrorInfoTest.java b/app/src/androidTest/java/org/schabi/newpipelegacy/report/ErrorInfoTest.java new file mode 100644 index 000000000..89d88bfb4 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipelegacy/report/ErrorInfoTest.java @@ -0,0 +1,39 @@ +package org.schabi.newpipelegacy.report; + +import android.os.Parcel; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.filters.LargeTest; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.report.ErrorActivity.ErrorInfo; + +import static org.junit.Assert.assertEquals; + +/** + * Instrumented tests for {@link ErrorInfo}. + */ +@RunWith(AndroidJUnit4.class) +@LargeTest +public class ErrorInfoTest { + + @Test + public void errorInfoTestParcelable() { + ErrorInfo info = ErrorInfo.make(UserAction.USER_REPORT, "youtube", "request", + R.string.general_error); + // Obtain a Parcel object and write the parcelable object to it: + Parcel parcel = Parcel.obtain(); + info.writeToParcel(parcel, 0); + parcel.setDataPosition(0); + ErrorInfo infoFromParcel = ErrorInfo.CREATOR.createFromParcel(parcel); + + assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction); + assertEquals("youtube", infoFromParcel.serviceName); + assertEquals("request", infoFromParcel.request); + assertEquals(R.string.general_error, infoFromParcel.message); + + parcel.recycle(); + } +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000..1c25999d2 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/app/src/debug/java/org/schabi/newpipelegacy/DebugApp.kt b/app/src/debug/java/org/schabi/newpipelegacy/DebugApp.kt new file mode 100644 index 000000000..9ae06a1f4 --- /dev/null +++ b/app/src/debug/java/org/schabi/newpipelegacy/DebugApp.kt @@ -0,0 +1,59 @@ +package org.schabi.newpipelegacy + +import android.content.Context +import androidx.multidex.MultiDex +import androidx.preference.PreferenceManager +import com.facebook.stetho.Stetho +import com.facebook.stetho.okhttp3.StethoInterceptor +import leakcanary.AppWatcher +import leakcanary.LeakCanary +import okhttp3.OkHttpClient +import org.schabi.newpipe.extractor.downloader.Downloader + +class DebugApp : App() { + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + MultiDex.install(this) + } + + override fun onCreate() { + super.onCreate() + initStetho() + + // Give each object 10 seconds to be GC'ed, before LeakCanary gets nosy on it + AppWatcher.config = AppWatcher.config.copy(watchDurationMillis = 10000) + LeakCanary.config = LeakCanary.config.copy(dumpHeap = PreferenceManager + .getDefaultSharedPreferences(this).getBoolean(getString( + R.string.allow_heap_dumping_key), false)) + } + + override fun getDownloader(): Downloader { + val downloader = DownloaderImpl.init(OkHttpClient.Builder() + .addNetworkInterceptor(StethoInterceptor())) + setCookiesToDownloader(downloader) + return downloader + } + + private fun initStetho() { + // Create an InitializerBuilder + val initializerBuilder = Stetho.newInitializerBuilder(this) + + // Enable Chrome DevTools + initializerBuilder.enableWebKitInspector(Stetho.defaultInspectorModulesProvider(this)) + + // Enable command line interface + initializerBuilder.enableDumpapp( + Stetho.defaultDumperPluginsProvider(applicationContext)) + + // Use the InitializerBuilder to generate an Initializer + val initializer = initializerBuilder.build() + + // Initialize Stetho with the Initializer + Stetho.initialize(initializer) + } + + override fun isDisposedRxExceptionsReported(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(getString(R.string.allow_disposed_exceptions_key), false) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..7306e2348 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,338 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/apache2.html b/app/src/main/assets/apache2.html new file mode 100644 index 000000000..de86cde66 --- /dev/null +++ b/app/src/main/assets/apache2.html @@ -0,0 +1,162 @@ + + + + + Apache License - Version 2.0, January 2004 + + +

Apache License
Version 2.0, January 2004
+ http://www.apache.org/licenses/

+

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

+

1. Definitions.

+

"License" shall mean the terms and conditions for use, reproduction, and + distribution as defined by Sections 1 through 9 of this document.

+

"Licensor" shall mean the copyright owner or entity authorized by the + copyright owner that is granting the License.

+

"Legal Entity" shall mean the union of the acting entity and all other + entities that control, are controlled by, or are under common control with + that entity. For the purposes of this definition, "control" means (i) the + power, direct or indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (ii) ownership of fifty + percent (50%) or more of the outstanding shares, or (iii) beneficial + ownership of such entity.

+

"You" (or "Your") shall mean an individual or Legal Entity exercising + permissions granted by this License.

+

"Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation source, + and configuration files.

+

"Object" form shall mean any form resulting from mechanical transformation + or translation of a Source form, including but not limited to compiled + object code, generated documentation, and conversions to other media types.

+

"Work" shall mean the work of authorship, whether in Source or Object form, + made available under the License, as indicated by a copyright notice that + is included in or attached to the work (an example is provided in the + Appendix below).

+

"Derivative Works" shall mean any work, whether in Source or Object form, + that is based on (or derived from) the Work and for which the editorial + revisions, annotations, elaborations, or other modifications represent, as + a whole, an original work of authorship. For the purposes of this License, + Derivative Works shall not include works that remain separable from, or + merely link (or bind by name) to the interfaces of, the Work and Derivative + Works thereof.

+

"Contribution" shall mean any work of authorship, including the original + version of the Work and any modifications or additions to that Work or + Derivative Works thereof, that is intentionally submitted to Licensor for + inclusion in the Work by the copyright owner or by an individual or Legal + Entity authorized to submit on behalf of the copyright owner. For the + purposes of this definition, "submitted" means any form of electronic, + verbal, or written communication sent to the Licensor or its + representatives, including but not limited to communication on electronic + mailing lists, source code control systems, and issue tracking systems that + are managed by, or on behalf of, the Licensor for the purpose of discussing + and improving the Work, but excluding communication that is conspicuously + marked or otherwise designated in writing by the copyright owner as "Not a + Contribution."

+

"Contributor" shall mean Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work.

+

2. Grant of Copyright License. Subject to the + terms and conditions of this License, each Contributor hereby grants to You + a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, publicly + display, publicly perform, sublicense, and distribute the Work and such + Derivative Works in Source or Object form.

+

3. Grant of Patent License. Subject to the terms + and conditions of this License, each Contributor hereby grants to You a + perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, use, + offer to sell, sell, import, and otherwise transfer the Work, where such + license applies only to those patent claims licensable by such Contributor + that are necessarily infringed by their Contribution(s) alone or by + combination of their Contribution(s) with the Work to which such + Contribution(s) was submitted. If You institute patent litigation against + any entity (including a cross-claim or counterclaim in a lawsuit) alleging + that the Work or a Contribution incorporated within the Work constitutes + direct or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate as of the + date such litigation is filed.

+

4. Redistribution. You may reproduce and + distribute copies of the Work or Derivative Works thereof in any medium, + with or without modifications, and in Source or Object form, provided that + You meet the following conditions:

+
    +
  1. You must give any other recipients of the Work or Derivative Works a + copy of this License; and
  2. + +
  3. You must cause any modified files to carry prominent notices stating + that You changed the files; and
  4. + +
  5. You must retain, in the Source form of any Derivative Works that You + distribute, all copyright, patent, trademark, and attribution notices from + the Source form of the Work, excluding those notices that do not pertain to + any part of the Derivative Works; and
  6. + +
  7. If the Work includes a "NOTICE" text file as part of its distribution, + then any Derivative Works that You distribute must include a readable copy + of the attribution notices contained within such NOTICE file, excluding + those notices that do not pertain to any part of the Derivative Works, in + at least one of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or documentation, + if provided along with the Derivative Works; or, within a display generated + by the Derivative Works, if and wherever such third-party notices normally + appear. The contents of the NOTICE file are for informational purposes only + and do not modify the License. You may add Your own attribution notices + within Derivative Works that You distribute, alongside or as an addendum to + the NOTICE text from the Work, provided that such additional attribution + notices cannot be construed as modifying the License. +
    +
    + You may add Your own copyright statement to Your modifications and may + provide additional or different license terms and conditions for use, + reproduction, or distribution of Your modifications, or for any such + Derivative Works as a whole, provided Your use, reproduction, and + distribution of the Work otherwise complies with the conditions stated in + this License. +
  8. + +
+ +

5. Submission of Contributions. Unless You + explicitly state otherwise, any Contribution intentionally submitted for + inclusion in the Work by You to the Licensor shall be under the terms and + conditions of this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify the + terms of any separate license agreement you may have executed with Licensor + regarding such Contributions.

+

6. Trademarks. This License does not grant + permission to use the trade names, trademarks, service marks, or product + names of the Licensor, except as required for reasonable and customary use + in describing the origin of the Work and reproducing the content of the + NOTICE file.

+

7. Disclaimer of Warranty. Unless required by + applicable law or agreed to in writing, Licensor provides the Work (and + each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT + WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, + without limitation, any warranties or conditions of TITLE, + NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You + are solely responsible for determining the appropriateness of using or + redistributing the Work and assume any risks associated with Your exercise + of permissions under this License.

+

8. Limitation of Liability. In no event and + under no legal theory, whether in tort (including negligence), contract, or + otherwise, unless required by applicable law (such as deliberate and + grossly negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a result + of this License or out of the use or inability to use the Work (including + but not limited to damages for loss of goodwill, work stoppage, computer + failure or malfunction, or any and all other commercial damages or losses), + even if such Contributor has been advised of the possibility of such + damages.

+

9. Accepting Warranty or Additional Liability. + While redistributing the Work or Derivative Works thereof, You may choose + to offer, and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this License. + However, in accepting such obligations, You may act only on Your own behalf + and on Your sole responsibility, not on behalf of any other Contributor, + and only if You agree to indemnify, defend, and hold each Contributor + harmless for any liability incurred by, or claims asserted against, such + Contributor by reason of your accepting any such warranty or additional + liability.

+ + \ No newline at end of file diff --git a/app/src/main/assets/gpl_2.html b/app/src/main/assets/gpl_2.html new file mode 100644 index 000000000..0e1b8827e --- /dev/null +++ b/app/src/main/assets/gpl_2.html @@ -0,0 +1,400 @@ + + + + + + GNU General Public License v2.0 - GNU Project - Free Software Foundation (FSF) + + + +

GNU GENERAL PUBLIC LICENSE

+

+Version 2, June 1991 +

+ +
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.
+51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. +
+ +

Preamble

+ +

+ The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. +

+ +

+ When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. +

+ +

+ To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. +

+ +

+ For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. +

+ +

+ We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. +

+ +

+ Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. +

+ +

+ Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. +

+ +

+ The precise terms and conditions for copying, distribution and +modification follow. +

+ + +

TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION

+ + +

+0. + This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". +

+ +

+Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. +

+ +

+1. + You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. +

+ +

+You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. +

+ +

+2. + You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: +

+ +
+
+
+ a) + You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. +
+
+
+ b) + You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. +
+
+
+ c) + If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) +
+
+ +

+These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. +

+ +

+Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. +

+ +

+In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. +

+ +

+3. + You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: +

+ + + + +
+
+
+ a) + Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, +
+
+
+ b) + Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, +
+
+
+ c) + Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) +
+
+ +

+The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major softwareComponents (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. +

+ +

+If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. +

+ +

+4. + You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. +

+ +

+5. + You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. +

+ +

+6. + Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. +

+ +

+7. + If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. +

+ +

+If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. +

+ +

+It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. +

+ +

+This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. +

+ +

+8. + If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. +

+ +

+9. + The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. +

+ +

+Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. +

+ +

+10. + If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. +

+ +

NO WARRANTY

+ +

+11. + BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. +

+ +

+12. + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. +

+ diff --git a/app/src/main/assets/gpl_3.html b/app/src/main/assets/gpl_3.html new file mode 100644 index 000000000..7e885a640 --- /dev/null +++ b/app/src/main/assets/gpl_3.html @@ -0,0 +1,639 @@ + + + + + + GNU General Public License v3.0 - GNU Project - Free Software Foundation (FSF) + + + +

GNU GENERAL PUBLIC LICENSE

+

Version 3, 29 June 2007

+ +

Copyright © 2007 Free Software Foundation, Inc. + <http://fsf.org/>

+ Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed.

+ +

Preamble

+ +

The GNU General Public License is a free, copyleft license for +software and other kinds of works.

+ +

The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too.

+ +

When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things.

+ +

To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others.

+ +

For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights.

+ +

Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it.

+ +

For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions.

+ +

Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users.

+ +

Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free.

+ +

The precise terms and conditions for copying, distribution and +modification follow.

+ +

TERMS AND CONDITIONS

+ +

0. Definitions.

+ +

“This License” refers to version 3 of the GNU General Public License.

+ +

“Copyright” also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks.

+ +

“The Program” refers to any copyrightable work licensed under this +License. Each licensee is addressed as “you”. “Licensees” and +“recipients” may be individuals or organizations.

+ +

To “modify” a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a “modified version” of the +earlier work or a work “based on” the earlier work.

+ +

A “covered work” means either the unmodified Program or a work based +on the Program.

+ +

To “propagate” a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well.

+ +

To “convey” a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying.

+ +

An interactive user interface displays “Appropriate Legal Notices” +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion.

+ +

1. Source Code.

+ +

The “source code” for a work means the preferred form of the work +for making modifications to it. “Object code” means any non-source +form of a work.

+ +

A “Standard Interface” means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language.

+ +

The “System Libraries” of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +“Major Component”, in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it.

+ +

The “Corresponding Source” for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work.

+ +

The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source.

+ +

The Corresponding Source for a work in source code form is that +same work.

+ +

2. Basic Permissions.

+ +

All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law.

+ +

You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you.

+ +

Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary.

+ +

3. Protecting Users' Legal Rights From Anti-Circumvention Law.

+ +

No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures.

+ +

When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures.

+ +

4. Conveying Verbatim Copies.

+ +

You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program.

+ +

You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee.

+ +

5. Conveying Modified Source Versions.

+ +

You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions:

+ +
    +
  • a) The work must carry prominent notices stating that you modified + it, and giving a relevant date.
  • + +
  • b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + “keep intact all notices”.
  • + +
  • c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it.
  • + +
  • d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so.
  • +
+ +

A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +“aggregate” if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate.

+ +

6. Conveying Non-Source Forms.

+ +

You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways:

+ +
    +
  • a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange.
  • + +
  • b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge.
  • + +
  • c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b.
  • + +
  • d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements.
  • + +
  • e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d.
  • +
+ +

A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work.

+ +

A “User Product” is either (1) a “consumer product”, which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, “normally used” refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product.

+ +

“Installation Information” for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made.

+ +

If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM).

+ +

The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network.

+ +

Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying.

+ +

7. Additional Terms.

+ +

“Additional permissions” are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions.

+ +

When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission.

+ +

Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms:

+ +
    +
  • a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or
  • + +
  • b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or
  • + +
  • c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or
  • + +
  • d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or
  • + +
  • e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or
  • + +
  • f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors.
  • +
+ +

All other non-permissive additional terms are considered “further +restrictions” within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying.

+ +

If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms.

+ +

Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way.

+ +

8. Termination.

+ +

You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11).

+ +

However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation.

+ +

Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice.

+ +

Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10.

+ +

9. Acceptance Not Required for Having Copies.

+ +

You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so.

+ +

10. Automatic Licensing of Downstream Recipients.

+ +

Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License.

+ +

An “entity transaction” is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts.

+ +

You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it.

+ +

11. Patents.

+ +

A “contributor” is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's “contributor version”.

+ +

A contributor's “essential patent claims” are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, “control” includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License.

+ +

Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version.

+ +

In the following three paragraphs, a “patent license” is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To “grant” such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party.

+ +

If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. “Knowingly relying” means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid.

+ +

If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it.

+ +

A patent license is “discriminatory” if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007.

+ +

Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law.

+ +

12. No Surrender of Others' Freedom.

+ +

If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program.

+ +

13. Use with the GNU Affero General Public License.

+ +

Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such.

+ +

14. Revised Versions of this License.

+ +

The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns.

+ +

Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License “or any later version” applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation.

+ +

If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program.

+ +

Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version.

+ +

15. Disclaimer of Warranty.

+ +

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

+ +

16. Limitation of Liability.

+ +

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES.

+ +

17. Interpretation of Sections 15 and 16.

+ +

If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee.

+ + diff --git a/app/src/main/assets/mit.html b/app/src/main/assets/mit.html new file mode 100644 index 000000000..909d61acb --- /dev/null +++ b/app/src/main/assets/mit.html @@ -0,0 +1,26 @@ + + + +

Copyright (c) <year> <copyright holders>

+ +

Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions:

+ +

+The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software.

+

+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
+NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ + diff --git a/app/src/main/assets/mpl2.html b/app/src/main/assets/mpl2.html new file mode 100644 index 000000000..5e988a70c --- /dev/null +++ b/app/src/main/assets/mpl2.html @@ -0,0 +1,261 @@ + + + + + + Mozilla Public License, version 2.0 + + +

Mozilla Public License
Version 2.0

+

1. Definitions

+
+
1.1. “Contributor”
+

means each individual or legal entity that creates, contributes to the creation of, or + owns Covered Software.

+
+
1.2. “Contributor Version”
+

means the combination of the Contributions of others (if any) used by a Contributor and + that particular Contributor’s Contribution.

+
+
1.3. “Contribution”
+

means Covered Software of a particular Contributor.

+
+
1.4. “Covered Software”
+

means Source Code Form to which the initial Contributor has attached the notice in + Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source + Code Form, in each case including portions thereof.

+
+
1.5. “Incompatible With Secondary Licenses”
+

means

+
    +
  1. that the initial Contributor has attached the notice described in Exhibit B to + the Covered Software; or

  2. +
  3. that the Covered Software was made available under the terms of version 1.1 or + earlier of the License, but not also under the terms of a Secondary License.

    +
  4. +
+
+
1.6. “Executable Form”
+

means any form of the work other than Source Code Form.

+
+
1.7. “Larger Work”
+

means a work that combines Covered Software with other material, in a separate file or + files, that is not Covered Software.

+
+
1.8. “License”
+

means this document.

+
+
1.9. “Licensable”
+

means having the right to grant, to the maximum extent possible, whether at the time of + the initial grant or subsequently, any and all of the rights conveyed by this License.

+
+
1.10. “Modifications”
+

means any of the following:

+
    +
  1. any file in Source Code Form that results from an addition to, deletion from, or + modification of the contents of Covered Software; or

  2. +
  3. any new file in Source Code Form that contains any Covered Software.

  4. +
+
+
1.11. “Patent Claims” of a Contributor
+

means any patent claim(s), including without limitation, method, process, and apparatus + claims, in any patent Licensable by such Contributor that would be infringed, but for the + grant of the License, by the making, using, selling, offering for sale, having made, import, + or transfer of either its Contributions or its Contributor Version.

+
+
1.12. “Secondary License”
+

means either the GNU General Public License, Version 2.0, the GNU Lesser General Public + License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later + versions of those licenses.

+
+
1.13. “Source Code Form”
+

means the form of the work preferred for making modifications.

+
+
1.14. “You” (or “Your”)
+

means an individual or a legal entity exercising rights under this License. For legal + entities, “You” includes any entity that controls, is controlled by, or is under common + control with You. For purposes of this definition, “control” means (a) the power, direct or + indirect, to cause the direction or management of such entity, whether by contract or + otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or + beneficial ownership of such entity.

+
+
+

2. License Grants and Conditions

+

2.1. Grants

+

Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license:

+
    +
  1. under intellectual property rights (other than patent or trademark) Licensable by such + Contributor to use, reproduce, make available, modify, display, perform, distribute, and + otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and

  2. +
  3. under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, + import, and otherwise transfer either its Contributions or its Contributor Version.

  4. +
+

2.2. Effective Date

+

The licenses granted in Section 2.1 with respect to any Contribution become effective for + each Contribution on the date the Contributor first distributes such Contribution.

+

2.3. Limitations on Grant Scope

+

The licenses granted in this Section 2 are the only rights granted under this License. No + additional rights or licenses will be implied from the distribution or licensing of Covered + Software under this License. Notwithstanding Section 2.1(b) above, no patent license is + granted by a Contributor:

+
    +
  1. for any code that a Contributor has removed from Covered Software; or

  2. +
  3. for infringements caused by: (i) Your and any other third party’s modifications of + Covered Software, or (ii) the combination of its Contributions with other software (except + as part of its Contributor Version); or

  4. +
  5. under Patent Claims infringed by Covered Software in the absence of its + Contributions.

  6. +
+

This License does not grant any rights in the trademarks, service marks, or logos of any + Contributor (except as may be necessary to comply with the notice requirements in Section 3.4).

+

2.4. Subsequent Licenses

+

No Contributor makes additional grants as a result of Your choice to distribute the Covered + Software under a subsequent version of this License (see Section 10.2) or under the terms + of a Secondary License (if permitted under the terms of Section 3.3).

+

2.5. Representation

+

Each Contributor represents that the Contributor believes its Contributions are its original + creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by + this License.

+

2.6. Fair Use

+

This License is not intended to limit any rights You have under applicable copyright doctrines of + fair use, fair dealing, or other equivalents.

+

2.7. Conditions

+

Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.

+

3. Responsibilities

+

3.1. Distribution of Source Form

+

All distribution of Covered Software in Source Code Form, including any Modifications that You + create or to which You contribute, must be under the terms of this License. You must inform + recipients that the Source Code Form of the Covered Software is governed by the terms of this + License, and how they can obtain a copy of this License. You may not attempt to alter or + restrict the recipients’ rights in the Source Code Form.

+

3.2. Distribution of Executable Form

+

If You distribute Covered Software in Executable Form then:

+
    +
  1. such Covered Software must also be made available in Source Code Form, as described in + Section 3.1, and You must inform recipients of the Executable Form how they can obtain + a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and

  2. +
  3. You may distribute such Executable Form under the terms of this License, or sublicense it + under different terms, provided that the license for the Executable Form does not attempt to + limit or alter the recipients’ rights in the Source Code Form under this License.

  4. +
+

3.3. Distribution of a Larger Work

+

You may create and distribute a Larger Work under terms of Your choice, provided that You also + comply with the requirements of this License for the Covered Software. If the Larger Work is a + combination of Covered Software with a work governed by one or more Secondary Licenses, and the + Covered Software is not Incompatible With Secondary Licenses, this License permits You to + additionally distribute such Covered Software under the terms of such Secondary License(s), so + that the recipient of the Larger Work may, at their option, further distribute the Covered + Software under the terms of either this License or such Secondary License(s).

+

3.4. Notices

+

You may not remove or alter the substance of any license notices (including copyright notices, + patent notices, disclaimers of warranty, or limitations of liability) contained within the + Source Code Form of the Covered Software, except that You may alter any license notices to the + extent required to remedy known factual inaccuracies.

+

3.5. Application of Additional Terms

+

You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability + obligations to one or more recipients of Covered Software. However, You may do so only on Your + own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any + such warranty, support, indemnity, or liability obligation is offered by You alone, and You + hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a + result of warranty, support, indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any jurisdiction.

+

4. Inability to Comply Due to Statute or + Regulation

+

If it is impossible for You to comply with any of the terms of this License with respect to some + or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) + comply with the terms of this License to the maximum extent possible; and (b) describe the + limitations and the code they affect. Such description must be placed in a text file included + with all distributions of the Covered Software under this License. Except to the extent + prohibited by statute or regulation, such description must be sufficiently detailed for a + recipient of ordinary skill to be able to understand it.

+

5. Termination

+

5.1. The rights granted under this License will terminate automatically if You fail to comply + with any of its terms. However, if You become compliant, then the rights granted under this + License from a particular Contributor are reinstated (a) provisionally, unless and until such + Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such + Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days + after You have come back into compliance. Moreover, Your grants from a particular Contributor + are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by + some reasonable means, this is the first time You have received notice of non-compliance with + this License from such Contributor, and You become compliant prior to 30 days after Your receipt + of the notice.

+

5.2. If You initiate litigation against any entity by asserting a patent infringement claim + (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a + Contributor Version directly or indirectly infringes any patent, then the rights granted to You + by any and all Contributors for the Covered Software under Section 2.1 of this License + shall terminate.

+

5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license + agreements (excluding distributors and resellers) which have been validly granted by You or Your + distributors under this License prior to termination shall survive termination.

+

6. Disclaimer of Warranty

+

Covered Software is provided under this License on an “as is” basis, without warranty of any + kind, either expressed, implied, or statutory, including, without limitation, warranties that + the Covered Software is free of defects, merchantable, fit for a particular purpose or + non-infringing. The entire risk as to the quality and performance of the Covered Software is + with You. Should any Covered Software prove defective in any respect, You (not any Contributor) + assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty + constitutes an essential part of this License. No use of any Covered Software is authorized + under this License except under this disclaimer.

+

7. Limitation of Liability

+

Under no circumstances and under no legal theory, whether tort (including negligence), + contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as + permitted above, be liable to You for any direct, indirect, special, incidental, or + consequential damages of any character including, without limitation, damages for lost profits, + loss of goodwill, work stoppage, computer failure or malfunction, or any and all other + commercial damages or losses, even if such party shall have been informed of the possibility of + such damages. This limitation of liability shall not apply to liability for death or personal + injury resulting from such party’s negligence to the extent applicable law prohibits such + limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or + consequential damages, so this exclusion and limitation may not apply to You.

+

8. Litigation

+

Any litigation relating to this License may be brought only in the courts of a jurisdiction where + the defendant maintains its principal place of business and such litigation shall be governed by + laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this + Section shall prevent a party’s ability to bring cross-claims or counter-claims.

+

9. Miscellaneous

+

This License represents the complete agreement concerning the subject matter hereof. If any + provision of this License is held to be unenforceable, such provision shall be reformed only to + the extent necessary to make it enforceable. Any law or regulation which provides that the + language of a contract shall be construed against the drafter shall not be used to construe this + License against a Contributor.

+

10. Versions of the License

+

10.1. New Versions

+

Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other + than the license steward has the right to modify or publish new versions of this License. Each + version will be given a distinguishing version number.

+

10.2. Effect of New Versions

+

You may distribute the Covered Software under the terms of the version of the License under which + You originally received the Covered Software, or under the terms of any subsequent version + published by the license steward.

+

10.3. Modified Versions

+

If you create software not governed by this License, and you want to create a new license for + such software, you may create and use a modified version of this License if you rename the + license and remove any references to the name of the license steward (except to note that such + modified license differs from this License).

+

10.4. + Distributing Source Code Form that is Incompatible With Secondary Licenses

+

If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under + the terms of this version of the License, the notice described in Exhibit B of this License must + be attached.

+

Exhibit A - Source Code Form License + Notice

+
+

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a + copy of the MPL was not distributed with this file, You can obtain one at + https://mozilla.org/MPL/2.0/.

+
+

If it is not possible or desirable to put the notice in a particular file, then You may include + the notice in a location (such as a LICENSE file in a relevant directory) where a recipient + would be likely to look for such a notice.

+

You may add additional accurate notices of copyright ownership.

+

Exhibit B - “Incompatible With + Secondary Licenses” Notice

+
+

This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla + Public License, v. 2.0.

+
+ + + \ No newline at end of file diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java new file mode 100644 index 000000000..11f457b6c --- /dev/null +++ b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java @@ -0,0 +1,329 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.fragment.app; + +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.lifecycle.Lifecycle; +import androidx.viewpager.widget.PagerAdapter; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; + +// TODO: Replace this deprecated class with its ViewPager2 counterpart + +/** + * This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}. + *

+ * It includes a workaround to fix the menu visibility when the adapter is restored. + *

+ *

+ * When restoring the state of this adapter, all the fragments' menu visibility were set to false, + * effectively disabling the menu from the user until he switched pages or another event + * that triggered the menu to be visible again happened. + *

+ *

+ * Check out the changes in: + *

+ *
    + *
  • {@link #saveState()}
  • + *
  • {@link #restoreState(Parcelable, ClassLoader)}
  • + *
+ */ +@SuppressWarnings("deprecation") +public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter { + private static final String TAG = "FragmentStatePagerAdapt"; + private static final boolean DEBUG = false; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}) + private @interface Behavior { } + + /** + * Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current + * fragment changes. + * + * @deprecated This behavior relies on the deprecated + * {@link Fragment#setUserVisibleHint(boolean)} API. Use + * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement, + * {@link FragmentTransaction#setMaxLifecycle}. + * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) + */ + @Deprecated + public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0; + + /** + * Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED} + * state. All other Fragments are capped at {@link Lifecycle.State#STARTED}. + * + * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) + */ + public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1; + + private final FragmentManager mFragmentManager; + private final int mBehavior; + private FragmentTransaction mCurTransaction = null; + + private ArrayList mSavedState = new ArrayList(); + private ArrayList mFragments = new ArrayList(); + private Fragment mCurrentPrimaryItem = null; + + /** + * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround} + * that sets the fragment manager for the adapter. This is the equivalent of calling + * {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in + * {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}. + * + *

Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the + * current Fragment changes.

+ * + * @param fm fragment manager that will interact with this adapter + * @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with + * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} + */ + @Deprecated + public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm) { + this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); + } + + /** + * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}. + * + * If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current + * Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are + * capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is + * passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be + * callbacks to {@link Fragment#setUserVisibleHint(boolean)}. + * + * @param fm fragment manager that will interact with this adapter + * @param behavior determines if only current fragments are in a resumed state + */ + public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm, + @Behavior final int behavior) { + mFragmentManager = fm; + mBehavior = behavior; + } + + /** + * @param position the position of the item you want + * @return the {@link Fragment} associated with a specified position + */ + @NonNull + public abstract Fragment getItem(int position); + + @Override + public void startUpdate(@NonNull final ViewGroup container) { + if (container.getId() == View.NO_ID) { + throw new IllegalStateException("ViewPager with adapter " + this + + " requires a view id"); + } + } + + @SuppressWarnings("deprecation") + @NonNull + @Override + public Object instantiateItem(@NonNull final ViewGroup container, final int position) { + // If we already have this item instantiated, there is nothing + // to do. This can happen when we are restoring the entire pager + // from its saved state, where the fragment manager has already + // taken care of restoring the fragments we previously had instantiated. + if (mFragments.size() > position) { + Fragment f = mFragments.get(position); + if (f != null) { + return f; + } + } + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + + Fragment fragment = getItem(position); + if (DEBUG) { + Log.v(TAG, "Adding item #" + position + ": f=" + fragment); + } + if (mSavedState.size() > position) { + Fragment.SavedState fss = mSavedState.get(position); + if (fss != null) { + fragment.setInitialSavedState(fss); + } + } + while (mFragments.size() <= position) { + mFragments.add(null); + } + fragment.setMenuVisibility(false); + if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) { + fragment.setUserVisibleHint(false); + } + + mFragments.set(position, fragment); + mCurTransaction.add(container.getId(), fragment); + + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED); + } + + return fragment; + } + + @Override + public void destroyItem(@NonNull final ViewGroup container, final int position, + @NonNull final Object object) { + Fragment fragment = (Fragment) object; + + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + if (DEBUG) { + Log.v(TAG, "Removing item #" + position + ": f=" + object + + " v=" + ((Fragment) object).getView()); + } + while (mSavedState.size() <= position) { + mSavedState.add(null); + } + mSavedState.set(position, fragment.isAdded() + ? mFragmentManager.saveFragmentInstanceState(fragment) : null); + mFragments.set(position, null); + + mCurTransaction.remove(fragment); + if (fragment == mCurrentPrimaryItem) { + mCurrentPrimaryItem = null; + } + } + + @Override + @SuppressWarnings({"ReferenceEquality", "deprecation"}) + public void setPrimaryItem(@NonNull final ViewGroup container, final int position, + @NonNull final Object object) { + Fragment fragment = (Fragment) object; + if (fragment != mCurrentPrimaryItem) { + if (mCurrentPrimaryItem != null) { + mCurrentPrimaryItem.setMenuVisibility(false); + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED); + } else { + mCurrentPrimaryItem.setUserVisibleHint(false); + } + } + fragment.setMenuVisibility(true); + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction(); + } + mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED); + } else { + fragment.setUserVisibleHint(true); + } + + mCurrentPrimaryItem = fragment; + } + } + + @Override + public void finishUpdate(@NonNull final ViewGroup container) { + if (mCurTransaction != null) { + mCurTransaction.commitNowAllowingStateLoss(); + mCurTransaction = null; + } + } + + @Override + public boolean isViewFromObject(@NonNull final View view, @NonNull final Object object) { + return ((Fragment) object).getView() == view; + } + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private final String selectedFragment = "selected_fragment"; + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + @Override + @Nullable + public Parcelable saveState() { + Bundle state = null; + if (mSavedState.size() > 0) { + state = new Bundle(); + Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; + mSavedState.toArray(fss); + state.putParcelableArray("states", fss); + } + for (int i = 0; i < mFragments.size(); i++) { + Fragment f = mFragments.get(i); + if (f != null && f.isAdded()) { + if (state == null) { + state = new Bundle(); + } + String key = "f" + i; + mFragmentManager.putFragment(state, key, f); + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // Check if it's the same fragment instance + if (f == mCurrentPrimaryItem) { + state.putString(selectedFragment, key); + } + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + } + } + return state; + } + + @Override + public void restoreState(@Nullable final Parcelable state, @Nullable final ClassLoader loader) { + if (state != null) { + Bundle bundle = (Bundle) state; + bundle.setClassLoader(loader); + Parcelable[] fss = bundle.getParcelableArray("states"); + mSavedState.clear(); + mFragments.clear(); + if (fss != null) { + for (int i = 0; i < fss.length; i++) { + mSavedState.add((Fragment.SavedState) fss[i]); + } + } + Iterable keys = bundle.keySet(); + for (String key: keys) { + if (key.startsWith("f")) { + int index = Integer.parseInt(key.substring(1)); + Fragment f = mFragmentManager.getFragment(bundle, key); + if (f != null) { + while (mFragments.size() <= index) { + mFragments.add(null); + } + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + final boolean wasSelected = bundle.getString(selectedFragment, "") + .equals(key); + f.setMenuVisibility(wasSelected); + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + mFragments.set(index, f); + } else { + Log.w(TAG, "Bad fragment at key " + key); + } + } + } + } + } +} diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java new file mode 100644 index 000000000..7aad30600 --- /dev/null +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java @@ -0,0 +1,124 @@ +package com.google.android.material.appbar; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.widget.OverScroller; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +import java.lang.reflect.Field; + +// See https://stackoverflow.com/questions/56849221#57997489 +public final class FlingBehavior extends AppBarLayout.Behavior { + private final Rect focusScrollRect = new Rect(); + + public FlingBehavior(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + public boolean onRequestChildRectangleOnScreen( + @NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child, + @NonNull final Rect rectangle, final boolean immediate) { + + focusScrollRect.set(rectangle); + + coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect); + + int height = coordinatorLayout.getHeight(); + + if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) { + // the child is too big to fit inside ourselves completely, ignore request + return false; + } + + int dy; + + if (focusScrollRect.bottom > height) { + dy = focusScrollRect.top; + } else if (focusScrollRect.top < 0) { + // scrolling up + dy = -(height - focusScrollRect.bottom); + } else { + // nothing to do + return false; + } + + int consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0); + + return consumed == dy; + } + + public boolean onInterceptTouchEvent(final CoordinatorLayout parent, final AppBarLayout child, + final MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + // remove reference to old nested scrolling child + resetNestedScrollingChild(); + // Stop fling when your finger touches the screen + stopAppBarLayoutFling(); + break; + default: + break; + } + return super.onInterceptTouchEvent(parent, child, ev); + } + + @Nullable + private OverScroller getScrollerField() { + try { + Class headerBehaviorType = this.getClass() + .getSuperclass().getSuperclass().getSuperclass(); + if (headerBehaviorType != null) { + Field field = headerBehaviorType.getDeclaredField("scroller"); + field.setAccessible(true); + return ((OverScroller) field.get(this)); + } + } catch (NoSuchFieldException e) { + // ? + } catch (IllegalAccessException e) { + // ? + } + return null; + } + + @Nullable + private Field getLastNestedScrollingChildRefField() { + try { + Class headerBehaviorType = this.getClass().getSuperclass().getSuperclass(); + if (headerBehaviorType != null) { + Field field = headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef"); + field.setAccessible(true); + return field; + } + } catch (NoSuchFieldException e) { + // ? + } + return null; + } + + private void resetNestedScrollingChild() { + Field field = getLastNestedScrollingChildRefField(); + if (field != null) { + try { + Object value = field.get(this); + if (value != null) { + field.set(this, null); + } + } catch (IllegalAccessException e) { + // ? + } + } + } + + private void stopAppBarLayoutFling() { + OverScroller scroller = getScrollerField(); + if (scroller != null) { + scroller.forceFinished(true); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/ActivityCommunicator.java b/app/src/main/java/org/schabi/newpipelegacy/ActivityCommunicator.java new file mode 100644 index 000000000..3d0d1d334 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/ActivityCommunicator.java @@ -0,0 +1,47 @@ +package org.schabi.newpipelegacy; + +/* + * Created by Christian Schabesberger on 24.12.15. + * + * Copyright (C) Christian Schabesberger 2015 + * ActivityCommunicator.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 . + */ + +/** + * Singleton: + * Used to send data between certain Activity/Services within the same process. + * This can be considered as an ugly hack inside the Android universe. + **/ +public class ActivityCommunicator { + + private static ActivityCommunicator activityCommunicator; + private volatile Class returnActivity; + + public static ActivityCommunicator getCommunicator() { + if (activityCommunicator == null) { + activityCommunicator = new ActivityCommunicator(); + } + return activityCommunicator; + } + + public Class getReturnActivity() { + return returnActivity; + } + + public void setReturnActivity(final Class returnActivity) { + this.returnActivity = returnActivity; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/App.java b/app/src/main/java/org/schabi/newpipelegacy/App.java new file mode 100644 index 000000000..ef5fc504c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/App.java @@ -0,0 +1,269 @@ +package org.schabi.newpipelegacy; + +import android.annotation.TargetApi; +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import com.nostra13.universalimageloader.cache.memory.impl.LRULimitedMemoryCache; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; + +import org.acra.ACRA; +import org.acra.config.ACRAConfigurationException; +import org.acra.config.CoreConfiguration; +import org.acra.config.CoreConfigurationBuilder; +import org.acra.sender.ReportSenderFactory; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.downloader.Downloader; +import org.schabi.newpipelegacy.report.AcraReportSenderFactory; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.settings.SettingsActivity; +import org.schabi.newpipelegacy.util.ExceptionUtils; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.ServiceHelper; +import org.schabi.newpipelegacy.util.StateSaver; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.SocketException; +import java.util.Collections; +import java.util.List; + +import io.reactivex.exceptions.CompositeException; +import io.reactivex.exceptions.MissingBackpressureException; +import io.reactivex.exceptions.OnErrorNotImplementedException; +import io.reactivex.exceptions.UndeliverableException; +import io.reactivex.functions.Consumer; +import io.reactivex.plugins.RxJavaPlugins; + +/* + * Copyright (C) Hans-Christoph Steiner 2016 + * App.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 App extends Application { + protected static final String TAG = App.class.toString(); + @SuppressWarnings("unchecked") + private static final Class[] + REPORT_SENDER_FACTORY_CLASSES = new Class[]{AcraReportSenderFactory.class}; + private static App app; + + public static App getApp() { + return app; + } + + @Override + protected void attachBaseContext(final Context base) { + super.attachBaseContext(base); + + initACRA(); + } + + @Override + public void onCreate() { + super.onCreate(); + + app = this; + + // Initialize settings first because others inits can use its values + SettingsActivity.initSettings(this); + + NewPipe.init(getDownloader(), + Localization.getPreferredLocalization(this), + Localization.getPreferredContentCountry(this)); + Localization.init(getApplicationContext()); + + StateSaver.init(this); + initNotificationChannel(); + + ServiceHelper.initServices(this); + + // Initialize image loader + ImageLoader.getInstance().init(getImageLoaderConfigurations(10, 50)); + + configureRxJavaErrorHandler(); + + // Check for new version + new CheckForNewAppVersionTask().execute(); + } + + protected Downloader getDownloader() { + DownloaderImpl downloader = DownloaderImpl.init(null); + setCookiesToDownloader(downloader); + return downloader; + } + + protected void setCookiesToDownloader(final DownloaderImpl downloader) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()); + final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); + downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, "")); + downloader.updateYoutubeRestrictedModeCookies(getApplicationContext()); + } + + private void configureRxJavaErrorHandler() { + // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling + RxJavaPlugins.setErrorHandler(new Consumer() { + @Override + public void accept(@NonNull final Throwable throwable) { + Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : " + + "throwable = [" + throwable.getClass().getName() + "]"); + + final Throwable actualThrowable; + if (throwable instanceof UndeliverableException) { + // As UndeliverableException is a wrapper, + // get the cause of it to get the "real" exception + actualThrowable = throwable.getCause(); + } else { + actualThrowable = throwable; + } + + final List errors; + if (actualThrowable instanceof CompositeException) { + errors = ((CompositeException) actualThrowable).getExceptions(); + } else { + errors = Collections.singletonList(actualThrowable); + } + + for (final Throwable error : errors) { + if (isThrowableIgnored(error)) { + return; + } + if (isThrowableCritical(error)) { + reportException(error); + return; + } + } + + // Out-of-lifecycle exceptions should only be reported if a debug user wishes so, + // When exception is not reported, log it + if (isDisposedRxExceptionsReported()) { + reportException(actualThrowable); + } else { + Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable); + } + } + + private boolean isThrowableIgnored(@NonNull final Throwable throwable) { + // Don't crash the application over a simple network problem + return ExceptionUtils.hasAssignableCause(throwable, + // network api cancellation + IOException.class, SocketException.class, + // blocking code disposed + InterruptedException.class, InterruptedIOException.class); + } + + private boolean isThrowableCritical(@NonNull final Throwable throwable) { + // Though these exceptions cannot be ignored + return ExceptionUtils.hasAssignableCause(throwable, + NullPointerException.class, IllegalArgumentException.class, // bug in app + OnErrorNotImplementedException.class, MissingBackpressureException.class, + IllegalStateException.class); // bug in operator + } + + private void reportException(@NonNull final Throwable throwable) { + // Throw uncaught exception that will trigger the report system + Thread.currentThread().getUncaughtExceptionHandler() + .uncaughtException(Thread.currentThread(), throwable); + } + }); + } + + private ImageLoaderConfiguration getImageLoaderConfigurations(final int memoryCacheSizeMb, + final int diskCacheSizeMb) { + return new ImageLoaderConfiguration.Builder(this) + .memoryCache(new LRULimitedMemoryCache(memoryCacheSizeMb * 1024 * 1024)) + .diskCacheSize(diskCacheSizeMb * 1024 * 1024) + .imageDownloader(new ImageDownloader(getApplicationContext())) + .build(); + } + + private void initACRA() { + try { + final CoreConfiguration acraConfig = new CoreConfigurationBuilder(this) + .setReportSenderFactoryClasses(REPORT_SENDER_FACTORY_CLASSES) + .setBuildConfigClass(BuildConfig.class) + .build(); + ACRA.init(this, acraConfig); + } catch (ACRAConfigurationException ace) { + ace.printStackTrace(); + ErrorActivity.reportError(this, + ace, + null, + null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + "Could not initialize ACRA crash report", R.string.app_ui_crash)); + } + } + + public void initNotificationChannel() { + if (Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { + return; + } + + final String id = getString(R.string.notification_channel_id); + final CharSequence name = getString(R.string.notification_channel_name); + final String description = getString(R.string.notification_channel_description); + + // Keep this below DEFAULT to avoid making noise on every notification update + final int importance = NotificationManager.IMPORTANCE_LOW; + + NotificationChannel mChannel = new NotificationChannel(id, name, importance); + mChannel.setDescription(description); + + NotificationManager mNotificationManager = + (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + mNotificationManager.createNotificationChannel(mChannel); + + setUpUpdateNotificationChannel(importance); + } + + /** + * Set up notification channel for app update. + * + * @param importance + */ + @TargetApi(Build.VERSION_CODES.O) + private void setUpUpdateNotificationChannel(final int importance) { + final String appUpdateId + = getString(R.string.app_update_notification_channel_id); + final CharSequence appUpdateName + = getString(R.string.app_update_notification_channel_name); + final String appUpdateDescription + = getString(R.string.app_update_notification_channel_description); + + NotificationChannel appUpdateChannel + = new NotificationChannel(appUpdateId, appUpdateName, importance); + appUpdateChannel.setDescription(appUpdateDescription); + + NotificationManager appUpdateNotificationManager + = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + appUpdateNotificationManager.createNotificationChannel(appUpdateChannel); + } + + protected boolean isDisposedRxExceptionsReported() { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/BaseFragment.java b/app/src/main/java/org/schabi/newpipelegacy/BaseFragment.java new file mode 100644 index 000000000..fe622cfe3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/BaseFragment.java @@ -0,0 +1,127 @@ +package org.schabi.newpipelegacy; + +import android.content.Context; +import android.os.Bundle; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import icepick.Icepick; +import icepick.State; +import leakcanary.AppWatcher; + +public abstract class BaseFragment extends Fragment { + public static final ImageLoader IMAGE_LOADER = ImageLoader.getInstance(); + protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); + protected final boolean DEBUG = MainActivity.DEBUG; + protected AppCompatActivity activity; + //These values are used for controlling fragments when they are part of the frontpage + @State + protected boolean useAsFrontPage = false; + private boolean mIsVisibleToUser = false; + + public void useAsFrontPage(final boolean value) { + useAsFrontPage = value; + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + activity = (AppCompatActivity) context; + } + + @Override + public void onDetach() { + super.onDetach(); + activity = null; + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + if (DEBUG) { + Log.d(TAG, "onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + super.onCreate(savedInstanceState); + Icepick.restoreInstanceState(this, savedInstanceState); + if (savedInstanceState != null) { + onRestoreInstanceState(savedInstanceState); + } + } + + + @Override + public void onViewCreated(final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + if (DEBUG) { + Log.d(TAG, "onViewCreated() called with: " + + "rootView = [" + rootView + "], " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + initViews(rootView, savedInstanceState); + initListeners(); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } + + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + } + + @Override + public void onDestroy() { + super.onDestroy(); + + AppWatcher.INSTANCE.getObjectWatcher().watch(this); + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + mIsVisibleToUser = isVisibleToUser; + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + protected void initViews(final View rootView, final Bundle savedInstanceState) { + } + + protected void initListeners() { + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + public void setTitle(final String title) { + if (DEBUG) { + Log.d(TAG, "setTitle() called with: title = [" + title + "]"); + } + if ((!useAsFrontPage || mIsVisibleToUser) + && (activity != null && activity.getSupportActionBar() != null)) { + activity.getSupportActionBar().setDisplayShowTitleEnabled(true); + activity.getSupportActionBar().setTitle(title); + } + } + + protected FragmentManager getFM() { + return getParentFragment() == null + ? getFragmentManager() + : getParentFragment().getFragmentManager(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/CheckForNewAppVersionTask.java b/app/src/main/java/org/schabi/newpipelegacy/CheckForNewAppVersionTask.java new file mode 100644 index 000000000..6ec46c87c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/CheckForNewAppVersionTask.java @@ -0,0 +1,222 @@ +package org.schabi.newpipelegacy; + +import android.app.Application; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.Signature; +import android.net.ConnectivityManager; +import android.net.Uri; +import android.os.AsyncTask; +import android.preference.PreferenceManager; +import android.util.Log; + +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; + +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; + +/** + * AsyncTask to check if there is a newer version of the NewPipe github apk available or not. + * If there is a newer version we show a notification, informing the user. On tapping + * the notification, the user will be directed to the download link. + */ +public class CheckForNewAppVersionTask extends AsyncTask { + private static final boolean DEBUG = MainActivity.DEBUG; + private static final String TAG = CheckForNewAppVersionTask.class.getSimpleName(); + + private static final Application APP = App.getApp(); + private static final String GITHUB_APK_SHA1 + = "C7:A4:55:67:6C:34:97:42:10:CE:3B:02:8F:D4:11:E5:10:FB:01:17"; + private static final String NEWPIPE_API_URL = "https://newpipe.schabi.org/api/data.json"; + private static final String FLAVOR = "github_legacy"; + + /** + * Method to get the apk's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133. + * + * @return String with the apk's SHA1 fingeprint in hexadecimal + */ + private static String getCertificateSHA1Fingerprint() { + final PackageManager pm = APP.getPackageManager(); + final String packageName = APP.getPackageName(); + final int flags = PackageManager.GET_SIGNATURES; + PackageInfo packageInfo = null; + + try { + packageInfo = pm.getPackageInfo(packageName, flags); + } catch (PackageManager.NameNotFoundException e) { + ErrorActivity.reportError(APP, e, null, null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + "Could not find package info", R.string.app_ui_crash)); + } + + final Signature[] signatures = packageInfo.signatures; + final byte[] cert = signatures[0].toByteArray(); + final InputStream input = new ByteArrayInputStream(cert); + + X509Certificate c = null; + + try { + final CertificateFactory cf = CertificateFactory.getInstance("X509"); + c = (X509Certificate) cf.generateCertificate(input); + } catch (CertificateException e) { + ErrorActivity.reportError(APP, e, null, null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + "Certificate error", R.string.app_ui_crash)); + } + + String hexString = null; + + try { + MessageDigest md = MessageDigest.getInstance("SHA1"); + final byte[] publicKey = md.digest(c.getEncoded()); + hexString = byte2HexFormatted(publicKey); + } catch (NoSuchAlgorithmException | CertificateEncodingException e) { + ErrorActivity.reportError(APP, e, null, null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + "Could not retrieve SHA1 key", R.string.app_ui_crash)); + } + + return hexString; + } + + private static String byte2HexFormatted(final byte[] arr) { + final StringBuilder str = new StringBuilder(arr.length * 2); + + for (int i = 0; i < arr.length; i++) { + String h = Integer.toHexString(arr[i]); + final int l = h.length(); + if (l == 1) { + h = "0" + h; + } + if (l > 2) { + h = h.substring(l - 2, l); + } + str.append(h.toUpperCase()); + if (i < (arr.length - 1)) { + str.append(':'); + } + } + return str.toString(); + } + + public static boolean isGithubApk() { + return getCertificateSHA1Fingerprint().equals(GITHUB_APK_SHA1); + } + + @Override + protected void onPreExecute() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(APP); + + // Check if user has enabled/disabled update checking + // and if the current apk is a github one or not. + if (!prefs.getBoolean(APP.getString(R.string.update_app_key), true) || !isGithubApk()) { + this.cancel(true); + } + } + + @Override + protected String doInBackground(final Void... voids) { + if (isCancelled() || !isConnected()) { + return null; + } + + // Make a network request to get latest NewPipe data. + try { + return DownloaderImpl.getInstance().get(NEWPIPE_API_URL).responseBody(); + } catch (IOException | ReCaptchaException e) { + // connectivity problems, do not alarm user and fail silently + if (DEBUG) { + Log.w(TAG, Log.getStackTraceString(e)); + } + } + + return null; + } + + @Override + protected void onPostExecute(final String response) { + // Parse the json from the response. + if (response != null) { + + try { + final JsonObject githubStableObject = JsonParser.object().from(response) + .getObject("flavors").getObject(FLAVOR).getObject("stable"); + + final String versionName = githubStableObject.getString("version"); + final int versionCode = githubStableObject.getInt("version_code"); + final String apkLocationUrl = githubStableObject.getString("apk"); + + compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode); + + } catch (JsonParserException e) { + // connectivity problems, do not alarm user and fail silently + if (DEBUG) { + Log.w(TAG, Log.getStackTraceString(e)); + } + } + } + } + + /** + * Method to compare the current and latest available app version. + * If a newer version is available, we show the update notification. + * + * @param versionName Name of new version + * @param apkLocationUrl Url with the new apk + * @param versionCode Code of new version + */ + private void compareAppVersionAndShowNotification(final String versionName, + final String apkLocationUrl, + final int versionCode) { + int notificationId = 2000; + + if (BuildConfig.VERSION_CODE < versionCode) { + + // A pending intent to open the apk location url in the browser. + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkLocationUrl)); + final PendingIntent pendingIntent + = PendingIntent.getActivity(APP, 0, intent, 0); + + final NotificationCompat.Builder notificationBuilder = new NotificationCompat + .Builder(APP, APP.getString(R.string.app_update_notification_channel_id)) + .setSmallIcon(R.drawable.ic_newpipe_update) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .setContentTitle(APP.getString(R.string.app_update_notification_content_title)) + .setContentText(APP.getString(R.string.app_update_notification_content_text) + + " " + versionName); + + final NotificationManagerCompat notificationManager + = NotificationManagerCompat.from(APP); + notificationManager.notify(notificationId, notificationBuilder.build()); + } + } + + private boolean isConnected() { + final ConnectivityManager cm = + (ConnectivityManager) APP.getSystemService(Context.CONNECTIVITY_SERVICE); + return cm.getActiveNetworkInfo() != null + && cm.getActiveNetworkInfo().isConnected(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipelegacy/DownloaderImpl.java new file mode 100644 index 000000000..4459f88e0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/DownloaderImpl.java @@ -0,0 +1,282 @@ +package org.schabi.newpipelegacy; + +import android.content.Context; +import android.os.Build; +import android.preference.PreferenceManager; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.downloader.Downloader; +import org.schabi.newpipe.extractor.downloader.Request; +import org.schabi.newpipe.extractor.downloader.Response; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipelegacy.util.CookieUtils; +import org.schabi.newpipelegacy.util.InfoCache; +import org.schabi.newpipelegacy.util.TLSSocketFactoryCompat; + +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; + +import okhttp3.CipherSuite; +import okhttp3.ConnectionSpec; +import okhttp3.OkHttpClient; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; + +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + +public final class DownloaderImpl extends Downloader { + public static final String USER_AGENT + = "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:68.0) Gecko/20100101 Firefox/68.0"; + public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY + = "youtube_restricted_mode_key"; + public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; + public static final String YOUTUBE_DOMAIN = "youtube.com"; + + private static DownloaderImpl instance; + private Map mCookies; + private OkHttpClient client; + + private DownloaderImpl(final OkHttpClient.Builder builder) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + enableModernTLS(builder); + } + this.client = builder + .readTimeout(30, TimeUnit.SECONDS) +// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), +// 16 * 1024 * 1024)) + .build(); + this.mCookies = new HashMap<>(); + } + + /** + * It's recommended to call exactly once in the entire lifetime of the application. + * + * @param builder if null, default builder will be used + * @return a new instance of {@link DownloaderImpl} + */ + public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { + instance = new DownloaderImpl( + builder != null ? builder : new OkHttpClient.Builder()); + return instance; + } + + public static DownloaderImpl getInstance() { + return instance; + } + + /** + * Enable TLS 1.2 and 1.1 on Android Kitkat. This function is mostly taken + * from the documentation of OkHttpClient.Builder.sslSocketFactory(_,_). + *

+ * If there is an error, the function will safely fall back to doing nothing + * and printing the error to the console. + *

+ * + * @param builder The HTTPClient Builder on which TLS is enabled on (will be modified in-place) + */ + private static void enableModernTLS(final OkHttpClient.Builder builder) { + try { + // get the default TrustManager + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init((KeyStore) null); + TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); + if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { + throw new IllegalStateException("Unexpected default trust managers:" + + Arrays.toString(trustManagers)); + } + X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; + + // insert our own TLSSocketFactory + SSLSocketFactory sslSocketFactory = TLSSocketFactoryCompat.getInstance(); + + builder.sslSocketFactory(sslSocketFactory, trustManager); + + // This will try to enable all modern CipherSuites(+2 more) + // that are supported on the device. + // Necessary because some servers (e.g. Framatube.org) + // don't support the old cipher suites. + // https://github.com/square/okhttp/issues/4053#issuecomment-402579554 + List cipherSuites = new ArrayList<>(); + cipherSuites.addAll(ConnectionSpec.MODERN_TLS.cipherSuites()); + cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA); + cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA); + ConnectionSpec legacyTLS = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .cipherSuites(cipherSuites.toArray(new CipherSuite[0])) + .build(); + + builder.connectionSpecs(Arrays.asList(legacyTLS, ConnectionSpec.CLEARTEXT)); + } catch (KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + + public String getCookies(final String url) { + List resultCookies = new ArrayList<>(); + if (url.contains(YOUTUBE_DOMAIN)) { + String youtubeCookie = getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); + if (youtubeCookie != null) { + resultCookies.add(youtubeCookie); + } + } + // Recaptcha cookie is always added TODO: not sure if this is necessary + String recaptchaCookie = getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY); + if (recaptchaCookie != null) { + resultCookies.add(recaptchaCookie); + } + return CookieUtils.concatCookies(resultCookies); + } + + public String getCookie(final String key) { + return mCookies.get(key); + } + + public void setCookie(final String key, final String cookie) { + mCookies.put(key, cookie); + } + + public void removeCookie(final String key) { + mCookies.remove(key); + } + + public void updateYoutubeRestrictedModeCookies(final Context context) { + String restrictedModeEnabledKey = + context.getString(R.string.youtube_restricted_mode_enabled); + boolean restrictedModeEnabled = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(restrictedModeEnabledKey, false); + updateYoutubeRestrictedModeCookies(restrictedModeEnabled); + } + + public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedModeEnabled) { + if (youtubeRestrictedModeEnabled) { + setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY, + YOUTUBE_RESTRICTED_MODE_COOKIE); + } else { + removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); + } + InfoCache.getInstance().clearCache(); + } + + /** + * Get the size of the content that the url is pointing by firing a HEAD request. + * + * @param url an url pointing to the content + * @return the size of the content, in bytes + */ + public long getContentLength(final String url) throws IOException { + try { + final Response response = head(url); + return Long.parseLong(response.getHeader("Content-Length")); + } catch (NumberFormatException e) { + throw new IOException("Invalid content length", e); + } catch (ReCaptchaException e) { + throw new IOException(e); + } + } + + public InputStream stream(final String siteUrl) throws IOException { + try { + final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() + .method("GET", null).url(siteUrl) + .addHeader("User-Agent", USER_AGENT); + + String cookies = getCookies(siteUrl); + if (!cookies.isEmpty()) { + requestBuilder.addHeader("Cookie", cookies); + } + + final okhttp3.Request request = requestBuilder.build(); + final okhttp3.Response response = client.newCall(request).execute(); + final ResponseBody body = response.body(); + + if (response.code() == 429) { + throw new ReCaptchaException("reCaptcha Challenge requested", siteUrl); + } + + if (body == null) { + response.close(); + return null; + } + + return body.byteStream(); + } catch (ReCaptchaException e) { + throw new IOException(e.getMessage(), e.getCause()); + } + } + + @Override + public Response execute(@NonNull final Request request) + throws IOException, ReCaptchaException { + final String httpMethod = request.httpMethod(); + final String url = request.url(); + final Map> headers = request.headers(); + final byte[] dataToSend = request.dataToSend(); + + RequestBody requestBody = null; + if (dataToSend != null) { + requestBody = RequestBody.create(null, dataToSend); + } + + final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() + .method(httpMethod, requestBody).url(url) + .addHeader("User-Agent", USER_AGENT); + + String cookies = getCookies(url); + if (!cookies.isEmpty()) { + requestBuilder.addHeader("Cookie", cookies); + } + + for (Map.Entry> pair : headers.entrySet()) { + final String headerName = pair.getKey(); + final List headerValueList = pair.getValue(); + + if (headerValueList.size() > 1) { + requestBuilder.removeHeader(headerName); + for (String headerValue : headerValueList) { + requestBuilder.addHeader(headerName, headerValue); + } + } else if (headerValueList.size() == 1) { + requestBuilder.header(headerName, headerValueList.get(0)); + } + + } + + final okhttp3.Response response = client.newCall(requestBuilder.build()).execute(); + + if (response.code() == 429) { + response.close(); + + throw new ReCaptchaException("reCaptcha Challenge requested", url); + } + + final ResponseBody body = response.body(); + String responseBodyToReturn = null; + + if (body != null) { + responseBodyToReturn = body.string(); + } + + final String latestUrl = response.request().url().toString(); + return new Response(response.code(), response.message(), response.headers().toMultimap(), + responseBodyToReturn, latestUrl); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/ExitActivity.java b/app/src/main/java/org/schabi/newpipelegacy/ExitActivity.java new file mode 100644 index 000000000..0f5a63f8b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/ExitActivity.java @@ -0,0 +1,53 @@ +package org.schabi.newpipelegacy; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; + +/* + * Copyright (C) Hans-Christoph Steiner 2016 + * ExitActivity.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 ExitActivity extends Activity { + + public static void exitAndRemoveFromRecentApps(final Activity activity) { + Intent intent = new Intent(activity, ExitActivity.class); + + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS + | Intent.FLAG_ACTIVITY_CLEAR_TASK + | Intent.FLAG_ACTIVITY_NO_ANIMATION); + + activity.startActivity(intent); + } + + @SuppressLint("NewApi") + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT >= 21) { + finishAndRemoveTask(); + } else { + finish(); + } + + System.exit(0); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/ImageDownloader.java b/app/src/main/java/org/schabi/newpipelegacy/ImageDownloader.java new file mode 100644 index 000000000..1cbefaffc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/ImageDownloader.java @@ -0,0 +1,47 @@ +package org.schabi.newpipelegacy; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.preference.PreferenceManager; + +import com.nostra13.universalimageloader.core.download.BaseImageDownloader; + +import org.schabi.newpipe.extractor.NewPipe; + +import java.io.IOException; +import java.io.InputStream; + +public class ImageDownloader extends BaseImageDownloader { + private final Resources resources; + private final SharedPreferences preferences; + private final String downloadThumbnailKey; + + public ImageDownloader(final Context context) { + super(context); + this.resources = context.getResources(); + this.preferences = PreferenceManager.getDefaultSharedPreferences(context); + this.downloadThumbnailKey = context.getString(R.string.download_thumbnail_key); + } + + private boolean isDownloadingThumbnail() { + return preferences.getBoolean(downloadThumbnailKey, true); + } + + @SuppressLint("ResourceType") + @Override + public InputStream getStream(final String imageUri, final Object extra) throws IOException { + if (isDownloadingThumbnail()) { + return super.getStream(imageUri, extra); + } else { + return resources.openRawResource(R.drawable.dummy_thumbnail_dark); + } + } + + protected InputStream getStreamFromNetwork(final String imageUri, final Object extra) + throws IOException { + final DownloaderImpl downloader = (DownloaderImpl) NewPipe.getDownloader(); + return downloader.stream(imageUri); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/MainActivity.java b/app/src/main/java/org/schabi/newpipelegacy/MainActivity.java new file mode 100644 index 000000000..ca8480f08 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/MainActivity.java @@ -0,0 +1,747 @@ +/* + * Created by Christian Schabesberger on 02.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * DownloadActivity.java is part of NewPipe. + *

+ * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + *

+ * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +package org.schabi.newpipelegacy; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.ActionBarDrawerToggle; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.view.GravityCompat; +import androidx.drawerlayout.widget.DrawerLayout; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.google.android.material.navigation.NavigationView; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import org.schabi.newpipelegacy.fragments.BackPressable; +import org.schabi.newpipelegacy.fragments.MainFragment; +import org.schabi.newpipelegacy.fragments.detail.VideoDetailFragment; +import org.schabi.newpipelegacy.fragments.list.search.SearchFragment; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.KioskTranslator; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.PeertubeHelper; +import org.schabi.newpipelegacy.util.PermissionHelper; +import org.schabi.newpipelegacy.util.ServiceHelper; +import org.schabi.newpipelegacy.util.StateSaver; +import org.schabi.newpipelegacy.util.TLSSocketFactoryCompat; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.FocusOverlayView; + +import java.util.ArrayList; +import java.util.List; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class MainActivity extends AppCompatActivity { + private static final String TAG = "MainActivity"; + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + + private ActionBarDrawerToggle toggle; + private DrawerLayout drawer; + private NavigationView drawerItems; + private ImageView headerServiceIcon; + private TextView headerServiceView; + private Button toggleServiceButton; + + private boolean servicesShown = false; + private ImageView serviceArrow; + + private static final int ITEM_ID_SUBSCRIPTIONS = -1; + private static final int ITEM_ID_FEED = -2; + private static final int ITEM_ID_BOOKMARKS = -3; + private static final int ITEM_ID_DOWNLOADS = -4; + private static final int ITEM_ID_HISTORY = -5; + private static final int ITEM_ID_SETTINGS = 0; + private static final int ITEM_ID_ABOUT = 1; + + private static final int ORDER = 0; + + /*////////////////////////////////////////////////////////////////////////// + // Activity's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void onCreate(final Bundle savedInstanceState) { + if (DEBUG) { + Log.d(TAG, "onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + + // enable TLS1.1/1.2 for jelly bean and kitkat devices, to fix download and play for + // mediaCCC sources + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { + TLSSocketFactoryCompat.setAsDefault(); + } + + ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); + + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + if (getSupportFragmentManager() != null + && getSupportFragmentManager().getBackStackEntryCount() == 0) { + initFragments(); + } + + setSupportActionBar(findViewById(R.id.toolbar)); + try { + setupDrawer(); + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + + if (AndroidTvUtils.isTv(this)) { + FocusOverlayView.setupFocusObserver(this); + } + } + + private void setupDrawer() throws Exception { + final Toolbar toolbar = findViewById(R.id.toolbar); + drawer = findViewById(R.id.drawer_layout); + drawerItems = findViewById(R.id.navigation); + + //Tabs + int currentServiceId = ServiceHelper.getSelectedServiceId(this); + StreamingService service = NewPipe.getService(currentServiceId); + + int kioskId = 0; + + for (final String ks : service.getKioskList().getAvailableKiosks()) { + drawerItems.getMenu() + .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator + .getTranslatedKioskName(ks, this)) + .setIcon(KioskTranslator.getKioskIcon(ks, this)); + kioskId++; + } + + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, + R.string.tab_subscriptions) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_rss)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_bookmark)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_file_download)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_history)); + + //Settings and About + drawerItems.getMenu() + .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_settings)); + drawerItems.getMenu() + .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_info_outline)); + + toggle = new ActionBarDrawerToggle(this, drawer, toolbar, R.string.drawer_open, + R.string.drawer_close); + toggle.syncState(); + drawer.addDrawerListener(toggle); + drawer.addDrawerListener(new DrawerLayout.SimpleDrawerListener() { + private int lastService; + + @Override + public void onDrawerOpened(final View drawerView) { + lastService = ServiceHelper.getSelectedServiceId(MainActivity.this); + } + + @Override + public void onDrawerClosed(final View drawerView) { + if (servicesShown) { + toggleServices(); + } + if (lastService != ServiceHelper.getSelectedServiceId(MainActivity.this)) { + new Handler(Looper.getMainLooper()).post(MainActivity.this::recreate); + } + } + }); + + drawerItems.setNavigationItemSelectedListener(this::drawerItemSelected); + setupDrawerHeader(); + } + + private boolean drawerItemSelected(final MenuItem item) { + switch (item.getGroupId()) { + case R.id.menu_services_group: + changeService(item); + break; + case R.id.menu_tabs_group: + try { + tabSelected(item); + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + break; + case R.id.menu_options_about_group: + optionsAboutSelected(item); + break; + default: + return false; + } + + drawer.closeDrawers(); + return true; + } + + private void changeService(final MenuItem item) { + drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)) + .setChecked(false); + ServiceHelper.setSelectedServiceId(this, item.getItemId()); + drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)) + .setChecked(true); + } + + private void tabSelected(final MenuItem item) throws ExtractionException { + switch (item.getItemId()) { + case ITEM_ID_SUBSCRIPTIONS: + NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()); + break; + case ITEM_ID_FEED: + NavigationHelper.openFeedFragment(getSupportFragmentManager()); + break; + case ITEM_ID_BOOKMARKS: + NavigationHelper.openBookmarksFragment(getSupportFragmentManager()); + break; + case ITEM_ID_DOWNLOADS: + NavigationHelper.openDownloads(this); + break; + case ITEM_ID_HISTORY: + NavigationHelper.openStatisticFragment(getSupportFragmentManager()); + break; + default: + int currentServiceId = ServiceHelper.getSelectedServiceId(this); + StreamingService service = NewPipe.getService(currentServiceId); + String serviceName = ""; + + int kioskId = 0; + for (final String ks : service.getKioskList().getAvailableKiosks()) { + if (kioskId == item.getItemId()) { + serviceName = ks; + } + kioskId++; + } + + NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentServiceId, + serviceName); + break; + } + } + + private void optionsAboutSelected(final MenuItem item) { + switch (item.getItemId()) { + case ITEM_ID_SETTINGS: + NavigationHelper.openSettings(this); + break; + case ITEM_ID_ABOUT: + NavigationHelper.openAbout(this); + break; + } + } + + private void setupDrawerHeader() { + NavigationView navigationView = findViewById(R.id.navigation); + View hView = navigationView.getHeaderView(0); + + serviceArrow = hView.findViewById(R.id.drawer_arrow); + headerServiceIcon = hView.findViewById(R.id.drawer_header_service_icon); + headerServiceView = hView.findViewById(R.id.drawer_header_service_view); + toggleServiceButton = hView.findViewById(R.id.drawer_header_action_button); + toggleServiceButton.setOnClickListener(view -> toggleServices()); + + // If the current app name is bigger than the default "NewPipe" (7 chars), + // let the text view grow a little more as well. + if (getString(R.string.app_name).length() > "NewPipe".length()) { + final TextView headerTitle = hView.findViewById(R.id.drawer_header_newpipe_title); + final ViewGroup.LayoutParams layoutParams = headerTitle.getLayoutParams(); + layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; + headerTitle.setLayoutParams(layoutParams); + headerTitle.setMaxLines(2); + headerTitle.setMinWidth(getResources() + .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width)); + headerTitle.setMaxWidth(getResources() + .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width)); + } + } + + private void toggleServices() { + servicesShown = !servicesShown; + + drawerItems.getMenu().removeGroup(R.id.menu_services_group); + drawerItems.getMenu().removeGroup(R.id.menu_tabs_group); + drawerItems.getMenu().removeGroup(R.id.menu_options_about_group); + + if (servicesShown) { + showServices(); + } else { + try { + showTabs(); + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + } + } + + private void showServices() { + serviceArrow.setImageResource(R.drawable.ic_arrow_drop_up_white_24dp); + + for (StreamingService s : NewPipe.getServices()) { + final String title = s.getServiceInfo().getName() + + (ServiceHelper.isBeta(s) ? " (beta)" : ""); + + MenuItem menuItem = drawerItems.getMenu() + .add(R.id.menu_services_group, s.getServiceId(), ORDER, title) + .setIcon(ServiceHelper.getIcon(s.getServiceId())); + + // peertube specifics + if (s.getServiceId() == 3) { + enhancePeertubeMenu(s, menuItem); + } + } + drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)) + .setChecked(true); + } + + private void enhancePeertubeMenu(final StreamingService s, final MenuItem menuItem) { + PeertubeInstance currentInstace = PeertubeHelper.getCurrentInstance(); + menuItem.setTitle(currentInstace.getName() + (ServiceHelper.isBeta(s) ? " (beta)" : "")); + Spinner spinner = (Spinner) LayoutInflater.from(this) + .inflate(R.layout.instance_spinner_layout, null); + List instances = PeertubeHelper.getInstanceList(this); + List items = new ArrayList<>(); + int defaultSelect = 0; + for (PeertubeInstance instance : instances) { + items.add(instance.getName()); + if (instance.getUrl().equals(currentInstace.getUrl())) { + defaultSelect = items.size() - 1; + } + } + ArrayAdapter adapter = new ArrayAdapter<>(this, + R.layout.instance_spinner_item, items); + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(adapter); + spinner.setSelection(defaultSelect, false); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + PeertubeInstance newInstance = instances.get(position); + if (newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) { + return; + } + PeertubeHelper.selectInstance(newInstance, getApplicationContext()); + changeService(menuItem); + drawer.closeDrawers(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + getSupportFragmentManager().popBackStack(null, + FragmentManager.POP_BACK_STACK_INCLUSIVE); + recreate(); + }, 300); + } + + @Override + public void onNothingSelected(final AdapterView parent) { + + } + }); + menuItem.setActionView(spinner); + } + + private void showTabs() throws ExtractionException { + serviceArrow.setImageResource(R.drawable.ic_arrow_drop_down_white_24dp); + + //Tabs + int currentServiceId = ServiceHelper.getSelectedServiceId(this); + StreamingService service = NewPipe.getService(currentServiceId); + + int kioskId = 0; + + for (final String ks : service.getKioskList().getAvailableKiosks()) { + drawerItems.getMenu() + .add(R.id.menu_tabs_group, kioskId, ORDER, + KioskTranslator.getTranslatedKioskName(ks, this)) + .setIcon(KioskTranslator.getKioskIcon(ks, this)); + kioskId++; + } + + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_rss)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_bookmark)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_file_download)); + drawerItems.getMenu() + .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_history)); + + //Settings and About + drawerItems.getMenu() + .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_settings)); + drawerItems.getMenu() + .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) + .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_info_outline)); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (!isChangingConfigurations()) { + StateSaver.clearStateFiles(); + } + } + + @Override + protected void onResume() { + assureCorrectAppLanguage(this); + // Change the date format to match the selected language on resume + Localization.init(getApplicationContext()); + super.onResume(); + + // Close drawer on return, and don't show animation, + // so it looks like the drawer isn't open when the user returns to MainActivity + drawer.closeDrawer(GravityCompat.START, false); + try { + final int selectedServiceId = ServiceHelper.getSelectedServiceId(this); + final String selectedServiceName = NewPipe.getService(selectedServiceId) + .getServiceInfo().getName(); + headerServiceView.setText(selectedServiceName); + headerServiceIcon.setImageResource(ServiceHelper.getIcon(selectedServiceId)); + + headerServiceView.post(() -> headerServiceView.setSelected(true)); + toggleServiceButton.setContentDescription( + getString(R.string.drawer_header_description) + selectedServiceName); + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); + if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { + if (DEBUG) { + Log.d(TAG, "Theme has changed, recreating activity..."); + } + sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply(); + // https://stackoverflow.com/questions/10844112/ + // Briefly, let the activity resume + // properly posting the recreate call to end of the message queue + new Handler(Looper.getMainLooper()).post(MainActivity.this::recreate); + } + + if (sharedPreferences.getBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false)) { + if (DEBUG) { + Log.d(TAG, "main page has changed, recreating main fragment..."); + } + sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply(); + NavigationHelper.openMainActivity(this); + } + + final boolean isHistoryEnabled = sharedPreferences.getBoolean( + getString(R.string.enable_watch_history_key), true); + drawerItems.getMenu().findItem(ITEM_ID_HISTORY).setVisible(isHistoryEnabled); + } + + @Override + protected void onNewIntent(final Intent intent) { + if (DEBUG) { + Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]"); + } + if (intent != null) { + // Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...) + // to not destroy the already created backstack + String action = intent.getAction(); + if ((action != null && action.equals(Intent.ACTION_MAIN)) + && intent.hasCategory(Intent.CATEGORY_LAUNCHER)) { + return; + } + } + + super.onNewIntent(intent); + setIntent(intent); + handleIntent(intent); + } + + @Override + public void onBackPressed() { + if (DEBUG) { + Log.d(TAG, "onBackPressed() called"); + } + + if (AndroidTvUtils.isTv(this)) { + View drawerPanel = findViewById(R.id.navigation); + if (drawer.isDrawerOpen(drawerPanel)) { + drawer.closeDrawers(); + return; + } + } + + Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_holder); + // If current fragment implements BackPressable (i.e. can/wanna handle back press) + // delegate the back press to it + if (fragment instanceof BackPressable) { + if (((BackPressable) fragment).onBackPressed()) { + return; + } + } + + if (getSupportFragmentManager().getBackStackEntryCount() == 1) { + finish(); + } else { + super.onBackPressed(); + } + } + + @Override + public void onRequestPermissionsResult(final int requestCode, + @NonNull final String[] permissions, + @NonNull final int[] grantResults) { + for (int i : grantResults) { + if (i == PackageManager.PERMISSION_DENIED) { + return; + } + } + switch (requestCode) { + case PermissionHelper.DOWNLOADS_REQUEST_CODE: + NavigationHelper.openDownloads(this); + break; + case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE: + Fragment fragment = getSupportFragmentManager() + .findFragmentById(R.id.fragment_holder); + if (fragment instanceof VideoDetailFragment) { + ((VideoDetailFragment) fragment).openDownloadDialog(); + } + break; + } + } + + /** + * Implement the following diagram behavior for the up button: + *

+     *              +---------------+
+     *              |  Main Screen  +----+
+     *              +-------+-------+    |
+     *                      |            |
+     *                      ▲ Up         | Search Button
+     *                      |            |
+     *                 +----+-----+      |
+     *    +------------+  Search  |◄-----+
+     *    |            +----+-----+
+     *    |   Open          |
+     *    |  something      ▲ Up
+     *    |                 |
+     *    |    +------------+-------------+
+     *    |    |                          |
+     *    |    |  Video    <->  Channel   |
+     *    +---►|  Channel  <->  Playlist  |
+     *         |  Video    <->  ....      |
+     *         |                          |
+     *         +--------------------------+
+     * 
+ */ + private void onHomeButtonPressed() { + // If search fragment wasn't found in the backstack... + if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) { + // ...go to the main fragment + NavigationHelper.gotoMainFragment(getSupportFragmentManager()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]"); + } + super.onCreateOptionsMenu(menu); + + Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_holder); + if (!(fragment instanceof VideoDetailFragment)) { + findViewById(R.id.toolbar).findViewById(R.id.toolbar_spinner).setVisibility(View.GONE); + } + + if (!(fragment instanceof SearchFragment)) { + findViewById(R.id.toolbar).findViewById(R.id.toolbar_search_container) + .setVisibility(View.GONE); + } + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + } + + updateDrawerNavigation(); + + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (DEBUG) { + Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]"); + } + int id = item.getItemId(); + + switch (id) { + case android.R.id.home: + onHomeButtonPressed(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + private void initFragments() { + if (DEBUG) { + Log.d(TAG, "initFragments() called"); + } + StateSaver.clearStateFiles(); + if (getIntent() != null && getIntent().hasExtra(Constants.KEY_LINK_TYPE)) { + handleIntent(getIntent()); + } else { + NavigationHelper.gotoMainFragment(getSupportFragmentManager()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void updateDrawerNavigation() { + if (getSupportActionBar() == null) { + return; + } + + final Toolbar toolbar = findViewById(R.id.toolbar); + + final Fragment fragment = getSupportFragmentManager() + .findFragmentById(R.id.fragment_holder); + if (fragment instanceof MainFragment) { + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + if (toggle != null) { + toggle.syncState(); + toolbar.setNavigationOnClickListener(v -> drawer.openDrawer(GravityCompat.START)); + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); + } + } else { + drawer.setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + toolbar.setNavigationOnClickListener(v -> onHomeButtonPressed()); + } + } + + private void handleIntent(final Intent intent) { + try { + if (DEBUG) { + Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); + } + + if (intent.hasExtra(Constants.KEY_LINK_TYPE)) { + String url = intent.getStringExtra(Constants.KEY_URL); + int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); + String title = intent.getStringExtra(Constants.KEY_TITLE); + switch (((StreamingService.LinkType) intent + .getSerializableExtra(Constants.KEY_LINK_TYPE))) { + case STREAM: + boolean autoPlay = intent + .getBooleanExtra(VideoDetailFragment.AUTO_PLAY, false); + NavigationHelper.openVideoDetailFragment(getSupportFragmentManager(), + serviceId, url, title, autoPlay); + break; + case CHANNEL: + NavigationHelper.openChannelFragment(getSupportFragmentManager(), + serviceId, + url, + title); + break; + case PLAYLIST: + NavigationHelper.openPlaylistFragment(getSupportFragmentManager(), + serviceId, + url, + title); + break; + } + } else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) { + String searchString = intent.getStringExtra(Constants.KEY_SEARCH_STRING); + if (searchString == null) { + searchString = ""; + } + int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); + NavigationHelper.openSearchFragment( + getSupportFragmentManager(), + serviceId, + searchString); + + } else { + NavigationHelper.gotoMainFragment(getSupportFragmentManager()); + } + } catch (Exception e) { + ErrorActivity.reportUiError(this, e); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipelegacy/NewPipeDatabase.java new file mode 100644 index 000000000..ced7a62d0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/NewPipeDatabase.java @@ -0,0 +1,54 @@ +package org.schabi.newpipelegacy; + +import android.content.Context; +import android.database.Cursor; + +import androidx.annotation.NonNull; +import androidx.room.Room; + +import org.schabi.newpipelegacy.database.AppDatabase; + +import static org.schabi.newpipelegacy.database.AppDatabase.DATABASE_NAME; +import static org.schabi.newpipelegacy.database.Migrations.MIGRATION_1_2; +import static org.schabi.newpipelegacy.database.Migrations.MIGRATION_2_3; + +public final class NewPipeDatabase { + private static volatile AppDatabase databaseInstance; + + private NewPipeDatabase() { + //no instance + } + + private static AppDatabase getDatabase(final Context context) { + return Room + .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .build(); + } + + @NonNull + public static AppDatabase getInstance(@NonNull final Context context) { + AppDatabase result = databaseInstance; + if (result == null) { + synchronized (NewPipeDatabase.class) { + result = databaseInstance; + if (result == null) { + databaseInstance = getDatabase(context); + result = databaseInstance; + } + } + } + + return result; + } + + public static void checkpoint() { + if (databaseInstance == null) { + throw new IllegalStateException("database is not initialized"); + } + Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null); + if (c.moveToFirst() && c.getInt(0) == 1) { + throw new RuntimeException("Checkpoint was blocked from completing"); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/PanicResponderActivity.java b/app/src/main/java/org/schabi/newpipelegacy/PanicResponderActivity.java new file mode 100644 index 000000000..e42192d2e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/PanicResponderActivity.java @@ -0,0 +1,49 @@ +package org.schabi.newpipelegacy; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; + +/* + * Copyright (C) Hans-Christoph Steiner 2016 + * PanicResponderActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class PanicResponderActivity extends Activity { + public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; + + @SuppressLint("NewApi") + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = getIntent(); + if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { + // TODO: Explicitly clear the search results + // once they are restored when the app restarts + // or if the app reloads the current video after being killed, + // that should be cleared also + ExitActivity.exitAndRemoveFromRecentApps(this); + } + + if (Build.VERSION.SDK_INT >= 21) { + finishAndRemoveTask(); + } else { + finish(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipelegacy/ReCaptchaActivity.java new file mode 100644 index 000000000..8b3289f00 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/ReCaptchaActivity.java @@ -0,0 +1,248 @@ +package org.schabi.newpipelegacy; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.NavUtils; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +/* + * Created by beneth on 06.12.16. + * + * Copyright (C) Christian Schabesberger 2015 + * ReCaptchaActivity.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +public class ReCaptchaActivity extends AppCompatActivity { + public static final int RECAPTCHA_REQUEST = 10; + public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra"; + public static final String TAG = ReCaptchaActivity.class.toString(); + public static final String YT_URL = "https://www.youtube.com"; + public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies"; + + private WebView webView; + private String foundCookies = ""; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + ThemeHelper.setTheme(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_recaptcha); + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + String url = getIntent().getStringExtra(RECAPTCHA_URL_EXTRA); + if (url == null || url.isEmpty()) { + url = YT_URL; + } + + // set return to Cancel by default + setResult(RESULT_CANCELED); + + + webView = findViewById(R.id.reCaptchaWebView); + + // enable Javascript + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + + webView.setWebViewClient(new WebViewClient() { + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + @Override + public boolean shouldOverrideUrlLoading(final WebView view, + final WebResourceRequest request) { + String url = request.getUrl().toString(); + if (MainActivity.DEBUG) { + Log.d(TAG, "shouldOverrideUrlLoading: request.url=" + url); + } + + handleCookiesFromUrl(url); + return false; + } + + @Override + public boolean shouldOverrideUrlLoading(final WebView view, final String url) { + if (MainActivity.DEBUG) { + Log.d(TAG, "shouldOverrideUrlLoading: url=" + url); + } + + handleCookiesFromUrl(url); + return false; + } + + @Override + public void onPageFinished(final WebView view, final String url) { + super.onPageFinished(view, url); + handleCookiesFromUrl(url); + } + }); + + // cleaning cache, history and cookies from webView + webView.clearCache(true); + webView.clearHistory(); + android.webkit.CookieManager cookieManager = CookieManager.getInstance(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + cookieManager.removeAllCookies(aBoolean -> { + }); + } else { + cookieManager.removeAllCookie(); + } + + webView.loadUrl(url); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + getMenuInflater().inflate(R.menu.menu_recaptcha, menu); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setTitle(R.string.title_activity_recaptcha); + actionBar.setSubtitle(R.string.subtitle_activity_recaptcha); + } + + return true; + } + + @Override + public void onBackPressed() { + saveCookiesAndFinish(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + switch (id) { + case R.id.menu_item_done: + saveCookiesAndFinish(); + return true; + default: + return false; + } + } + + private void saveCookiesAndFinish() { + handleCookiesFromUrl(webView.getUrl()); // try to get cookies of unclosed page + if (MainActivity.DEBUG) { + Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies); + } + + if (!foundCookies.isEmpty()) { + // save cookies to preferences + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()); + final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); + prefs.edit().putString(key, foundCookies).apply(); + + // give cookies to Downloader class + DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies); + setResult(RESULT_OK); + } + + Intent intent = new Intent(this, org.schabi.newpipelegacy.MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + NavUtils.navigateUpTo(this, intent); + } + + + private void handleCookiesFromUrl(@Nullable final String url) { + if (MainActivity.DEBUG) { + Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url)); + } + + if (url == null) { + return; + } + + String cookies = CookieManager.getInstance().getCookie(url); + handleCookies(cookies); + + // sometimes cookies are inside the url + int abuseStart = url.indexOf("google_abuse="); + if (abuseStart != -1) { + int abuseEnd = url.indexOf("+path"); + + try { + String abuseCookie = url.substring(abuseStart + 13, abuseEnd); + abuseCookie = URLDecoder.decode(abuseCookie, "UTF-8"); + handleCookies(abuseCookie); + } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { + if (MainActivity.DEBUG) { + e.printStackTrace(); + Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at " + + abuseStart + " and ending at " + abuseEnd + " for url " + url); + } + } + } + } + + private void handleCookies(@Nullable final String cookies) { + if (MainActivity.DEBUG) { + Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies)); + } + + if (cookies == null) { + return; + } + + addYoutubeCookies(cookies); + // add here methods to extract cookies for other services + } + + private void addYoutubeCookies(@NonNull final String cookies) { + if (cookies.contains("s_gl=") || cookies.contains("goojf=") + || cookies.contains("VISITOR_INFO1_LIVE=") + || cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) { + // youtube seems to also need the other cookies: + addCookie(cookies); + } + } + + private void addCookie(final String cookie) { + if (foundCookies.contains(cookie)) { + return; + } + + if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) { + foundCookies += cookie; + } else if (foundCookies.endsWith(";")) { + foundCookies += " " + cookie; + } else { + foundCookies += "; " + cookie; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/RouterActivity.java b/app/src/main/java/org/schabi/newpipelegacy/RouterActivity.java new file mode 100644 index 000000000..b5f5e6137 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/RouterActivity.java @@ -0,0 +1,768 @@ +package org.schabi.newpipelegacy; + +import android.annotation.SuppressLint; +import android.app.IntentService; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.view.ContextThemeWrapper; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.app.NotificationCompat; +import androidx.fragment.app.FragmentManager; + +import org.schabi.newpipelegacy.download.DownloadDialog; +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.StreamingService.LinkType; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.player.playqueue.ChannelPlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlaylistPlayQueue; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.ListHelper; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.PermissionHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.util.urlfinder.UrlFinder; +import org.schabi.newpipelegacy.views.FocusOverlayView; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; + +import icepick.Icepick; +import icepick.State; +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.Consumer; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.AUDIO; +import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.VIDEO; +import static org.schabi.newpipelegacy.util.ThemeHelper.resolveResourceIdFromAttr; + +/** + * Get the url from the intent and open it in the chosen preferred player. + */ +public class RouterActivity extends AppCompatActivity { + public static final String INTERNAL_ROUTE_KEY = "internalRoute"; + /** + * Removes invisible separators (\p{Z}) and punctuation characters including + * brackets (\p{P}). See http://www.regular-expressions.info/unicode.html for + * more details. + */ + private static final String REGEX_REMOVE_FROM_URL = "[\\p{Z}\\p{P}]"; + protected final CompositeDisposable disposables = new CompositeDisposable(); + @State + protected int currentServiceId = -1; + @State + protected LinkType currentLinkType; + @State + protected int selectedRadioPosition = -1; + protected int selectedPreviously = -1; + protected String currentUrl; + protected boolean internalRoute = false; + private StreamingService currentService; + private boolean selectionIsDownload = false; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Icepick.restoreInstanceState(this, savedInstanceState); + + if (TextUtils.isEmpty(currentUrl)) { + currentUrl = getUrl(getIntent()); + + if (TextUtils.isEmpty(currentUrl)) { + handleText(); + finish(); + } + } + + internalRoute = getIntent().getBooleanExtra(INTERNAL_ROUTE_KEY, false); + + setTheme(ThemeHelper.isLightThemeSelected(this) + ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } + + @Override + protected void onStart() { + super.onStart(); + + handleUrl(currentUrl); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + disposables.clear(); + } + + private void handleUrl(final String url) { + disposables.add(Observable + .fromCallable(() -> { + if (currentServiceId == -1) { + currentService = NewPipe.getServiceByUrl(url); + currentServiceId = currentService.getServiceId(); + currentLinkType = currentService.getLinkTypeByUrl(url); + currentUrl = url; + } else { + currentService = NewPipe.getService(currentServiceId); + } + + return currentLinkType != LinkType.NONE; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + if (result) { + onSuccess(); + } else { + onError(); + } + }, this::handleError)); + } + + private void handleError(final Throwable error) { + error.printStackTrace(); + + if (error instanceof ExtractionException) { + Toast.makeText(this, R.string.url_not_supported_toast, Toast.LENGTH_LONG).show(); + } else { + ExtractorHelper.handleGeneralException(this, -1, null, error, + UserAction.SOMETHING_ELSE, null); + } + + finish(); + } + + private void onError() { + Toast.makeText(this, R.string.url_not_supported_toast, Toast.LENGTH_LONG).show(); + finish(); + } + + protected void onSuccess() { + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(this); + final String selectedChoiceKey = preferences + .getString(getString(R.string.preferred_open_action_key), + getString(R.string.preferred_open_action_default)); + + final String showInfoKey = getString(R.string.show_info_key); + final String videoPlayerKey = getString(R.string.video_player_key); + final String backgroundPlayerKey = getString(R.string.background_player_key); + final String popupPlayerKey = getString(R.string.popup_player_key); + final String downloadKey = getString(R.string.download_key); + final String alwaysAskKey = getString(R.string.always_ask_open_action_key); + + if (selectedChoiceKey.equals(alwaysAskKey)) { + final List choices + = getChoicesForService(currentService, currentLinkType); + + switch (choices.size()) { + case 1: + handleChoice(choices.get(0).key); + break; + case 0: + handleChoice(showInfoKey); + break; + default: + showDialog(choices); + break; + } + } else if (selectedChoiceKey.equals(showInfoKey)) { + handleChoice(showInfoKey); + } else if (selectedChoiceKey.equals(downloadKey)) { + handleChoice(downloadKey); + } else { + final boolean isExtVideoEnabled = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false); + final boolean isExtAudioEnabled = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false); + final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey) + || selectedChoiceKey.equals(popupPlayerKey); + final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey); + + if (currentLinkType != LinkType.STREAM) { + if (isExtAudioEnabled && isAudioPlayerSelected + || isExtVideoEnabled && isVideoPlayerSelected) { + Toast.makeText(this, R.string.external_player_unsupported_link_type, + Toast.LENGTH_LONG).show(); + handleChoice(showInfoKey); + return; + } + } + + final List capabilities + = currentService.getServiceInfo().getMediaCapabilities(); + + boolean serviceSupportsChoice = false; + if (isVideoPlayerSelected) { + serviceSupportsChoice = capabilities.contains(VIDEO); + } else if (selectedChoiceKey.equals(backgroundPlayerKey)) { + serviceSupportsChoice = capabilities.contains(AUDIO); + } + + if (serviceSupportsChoice) { + handleChoice(selectedChoiceKey); + } else { + handleChoice(showInfoKey); + } + } + } + + private void showDialog(final List choices) { + final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); + final Context themeWrapperContext = getThemeWrapperContext(); + + final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); + final LinearLayout rootLayout = (LinearLayout) inflater.inflate( + R.layout.preferred_player_dialog_view, null, false); + final RadioGroup radioGroup = rootLayout.findViewById(android.R.id.list); + + final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { + final int indexOfChild = radioGroup.indexOfChild( + radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())); + final AdapterChoiceItem choice = choices.get(indexOfChild); + + handleChoice(choice.key); + + if (which == DialogInterface.BUTTON_POSITIVE) { + preferences.edit() + .putString(getString(R.string.preferred_open_action_key), choice.key) + .apply(); + } + }; + + final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) + .setTitle(R.string.preferred_open_action_share_menu_title) + .setView(radioGroup) + .setCancelable(true) + .setNegativeButton(R.string.just_once, dialogButtonsClickListener) + .setPositiveButton(R.string.always, dialogButtonsClickListener) + .setOnDismissListener((dialog) -> { + if (!selectionIsDownload) { + finish(); + } + }) + .create(); + + //noinspection CodeBlock2Expr + alertDialog.setOnShowListener(dialog -> { + setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); + }); + + radioGroup.setOnCheckedChangeListener((group, checkedId) -> + setDialogButtonsState(alertDialog, true)); + final View.OnClickListener radioButtonsClickListener = v -> { + final int indexOfChild = radioGroup.indexOfChild(v); + if (indexOfChild == -1) { + return; + } + + selectedPreviously = selectedRadioPosition; + selectedRadioPosition = indexOfChild; + + if (selectedPreviously == selectedRadioPosition) { + handleChoice(choices.get(selectedRadioPosition).key); + } + }; + + int id = 12345; + for (AdapterChoiceItem item : choices) { + final RadioButton radioButton + = (RadioButton) inflater.inflate(R.layout.list_radio_icon_item, null); + radioButton.setText(item.description); + radioButton.setCompoundDrawablesWithIntrinsicBounds( + AppCompatResources.getDrawable(getApplicationContext(), item.icon), + null, null, null); + radioButton.setChecked(false); + radioButton.setId(id++); + radioButton.setLayoutParams(new RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + radioButton.setOnClickListener(radioButtonsClickListener); + radioGroup.addView(radioButton); + } + + if (selectedRadioPosition == -1) { + final String lastSelectedPlayer = preferences.getString( + getString(R.string.preferred_open_action_last_selected_key), null); + if (!TextUtils.isEmpty(lastSelectedPlayer)) { + for (int i = 0; i < choices.size(); i++) { + AdapterChoiceItem c = choices.get(i); + if (lastSelectedPlayer.equals(c.key)) { + selectedRadioPosition = i; + break; + } + } + } + } + + selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.size() - 1); + if (selectedRadioPosition != -1) { + ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); + } + selectedPreviously = selectedRadioPosition; + + alertDialog.show(); + + if (AndroidTvUtils.isTv(this)) { + FocusOverlayView.setupFocusObserver(alertDialog); + } + } + + private List getChoicesForService(final StreamingService service, + final LinkType linkType) { + final Context context = getThemeWrapperContext(); + + final List returnList = new ArrayList<>(); + final List capabilities + = service.getServiceInfo().getMediaCapabilities(); + + 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); + + returnList.add(new AdapterChoiceItem(getString(R.string.show_info_key), + getString(R.string.show_info), + resolveResourceIdFromAttr(context, R.attr.ic_info_outline))); + + if (capabilities.contains(VIDEO) && !(isExtVideoEnabled && linkType != LinkType.STREAM)) { + returnList.add(new AdapterChoiceItem(getString(R.string.video_player_key), + getString(R.string.video_player), + resolveResourceIdFromAttr(context, R.attr.ic_play_arrow))); + returnList.add(new AdapterChoiceItem(getString(R.string.popup_player_key), + getString(R.string.popup_player), + resolveResourceIdFromAttr(context, R.attr.ic_popup))); + } + + if (capabilities.contains(AUDIO) && !(isExtAudioEnabled && linkType != LinkType.STREAM)) { + returnList.add(new AdapterChoiceItem(getString(R.string.background_player_key), + getString(R.string.background_player), + resolveResourceIdFromAttr(context, R.attr.ic_headset))); + } + + returnList.add(new AdapterChoiceItem(getString(R.string.download_key), + getString(R.string.download), + resolveResourceIdFromAttr(context, R.attr.ic_file_download))); + + return returnList; + } + + private Context getThemeWrapperContext() { + return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this) + ? R.style.LightTheme : R.style.DarkTheme); + } + + private void setDialogButtonsState(final AlertDialog dialog, final boolean state) { + final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); + final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (negativeButton == null || positiveButton == null) { + return; + } + + negativeButton.setEnabled(state); + positiveButton.setEnabled(state); + } + + private void handleText() { + String searchString = getIntent().getStringExtra(Intent.EXTRA_TEXT); + int serviceId = getIntent().getIntExtra(Constants.KEY_SERVICE_ID, 0); + Intent intent = new Intent(getThemeWrapperContext(), MainActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString); + } + + private void handleChoice(final String selectedChoiceKey) { + final List validChoicesList = Arrays.asList(getResources() + .getStringArray(R.array.preferred_open_action_values_list)); + if (validChoicesList.contains(selectedChoiceKey)) { + PreferenceManager.getDefaultSharedPreferences(this).edit() + .putString(getString( + R.string.preferred_open_action_last_selected_key), selectedChoiceKey) + .apply(); + } + + if (selectedChoiceKey.equals(getString(R.string.popup_player_key)) + && !PermissionHelper.isPopupEnabled(this)) { + PermissionHelper.showPopupEnablementToast(this); + finish(); + return; + } + + if (selectedChoiceKey.equals(getString(R.string.download_key))) { + if (PermissionHelper.checkStoragePermissions(this, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + selectionIsDownload = true; + openDownloadDialog(); + } + return; + } + + // stop and bypass FetcherService if InfoScreen was selected since + // StreamDetailFragment can fetch data itself + if (selectedChoiceKey.equals(getString(R.string.show_info_key))) { + disposables.add(Observable + .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(intent -> { + if (!internalRoute) { + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + } + startActivity(intent); + + finish(); + }, this::handleError) + ); + return; + } + + final Intent intent = new Intent(this, FetcherService.class); + final Choice choice = new Choice(currentService.getServiceId(), currentLinkType, + currentUrl, selectedChoiceKey); + intent.putExtra(FetcherService.KEY_CHOICE, choice); + startService(intent); + + finish(); + } + + @SuppressLint("CheckResult") + private void openDownloadDialog() { + ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((@NonNull StreamInfo result) -> { + List sortedVideoStreams = ListHelper + .getSortedStreamVideosList(this, result.getVideoStreams(), + result.getVideoOnlyStreams(), false); + int selectedVideoStreamIndex = ListHelper + .getDefaultResolutionIndex(this, sortedVideoStreams); + + FragmentManager fm = getSupportFragmentManager(); + DownloadDialog downloadDialog = DownloadDialog.newInstance(result); + downloadDialog.setVideoStreams(sortedVideoStreams); + downloadDialog.setAudioStreams(result.getAudioStreams()); + downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); + downloadDialog.show(fm, "downloadDialog"); + fm.executePendingTransactions(); + downloadDialog.getDialog().setOnDismissListener(dialog -> { + finish(); + }); + }, (@NonNull Throwable throwable) -> { + onError(); + }); + } + + @Override + public void onRequestPermissionsResult(final int requestCode, + @NonNull final String[] permissions, + @NonNull final int[] grantResults) { + for (int i : grantResults) { + if (i == PackageManager.PERMISSION_DENIED) { + finish(); + return; + } + } + if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) { + openDownloadDialog(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Service Fetcher + //////////////////////////////////////////////////////////////////////////*/ + + private String removeHeadingGibberish(final String input) { + int start = 0; + for (int i = input.indexOf("://") - 1; i >= 0; i--) { + if (!input.substring(i, i + 1).matches("\\p{L}")) { + start = i + 1; + break; + } + } + return input.substring(start); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private String trim(final String input) { + if (input == null || input.length() < 1) { + return input; + } else { + String output = input; + while (output.length() > 0 && output.substring(0, 1).matches(REGEX_REMOVE_FROM_URL)) { + output = output.substring(1); + } + while (output.length() > 0 + && output.substring(output.length() - 1).matches(REGEX_REMOVE_FROM_URL)) { + output = output.substring(0, output.length() - 1); + } + return output; + } + } + + /** + * Retrieves all Strings which look remotely like URLs from a text. + * Used if NewPipe was called through share menu. + * + * @param sharedText text to scan for URLs. + * @return potential URLs + */ + protected String[] getUris(final String sharedText) { + final Collection result = new HashSet<>(); + if (sharedText != null) { + final String[] array = sharedText.split("\\p{Space}"); + for (String s : array) { + s = trim(s); + if (s.length() != 0) { + if (s.matches(".+://.+")) { + result.add(removeHeadingGibberish(s)); + } else if (s.matches(".+\\..+")) { + result.add("http://" + s); + } + } + } + } + return result.toArray(new String[result.size()]); + } + + private static class AdapterChoiceItem { + final String description; + final String key; + @DrawableRes + final int icon; + + AdapterChoiceItem(final String key, final String description, final int icon) { + this.description = description; + this.key = key; + this.icon = icon; + } + } + + private static class Choice implements Serializable { + final int serviceId; + final String url; + final String playerChoice; + final LinkType linkType; + + Choice(final int serviceId, final LinkType linkType, + final String url, final String playerChoice) { + this.serviceId = serviceId; + this.linkType = linkType; + this.url = url; + this.playerChoice = playerChoice; + } + + @Override + public String toString() { + return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice; + } + } + + public static class FetcherService extends IntentService { + + public static final String KEY_CHOICE = "key_choice"; + private static final int ID = 456; + private Disposable fetcher; + + public FetcherService() { + super(FetcherService.class.getSimpleName()); + } + + @Override + public void onCreate() { + super.onCreate(); + startForeground(ID, createNotification().build()); + } + + @Override + protected void onHandleIntent(@Nullable final Intent intent) { + if (intent == null) { + return; + } + + final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE); + if (!(serializable instanceof Choice)) { + return; + } + Choice playerChoice = (Choice) serializable; + handleChoice(playerChoice); + } + + public void handleChoice(final Choice choice) { + Single single = null; + UserAction userAction = UserAction.SOMETHING_ELSE; + + switch (choice.linkType) { + case STREAM: + single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_STREAM; + break; + case CHANNEL: + single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_CHANNEL; + break; + case PLAYLIST: + single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false); + userAction = UserAction.REQUESTED_PLAYLIST; + break; + } + + + if (single != null) { + final UserAction finalUserAction = userAction; + final Consumer resultHandler = getResultHandler(choice); + fetcher = single + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + resultHandler.accept(info); + if (fetcher != null) { + fetcher.dispose(); + } + }, throwable -> ExtractorHelper.handleGeneralException(this, + choice.serviceId, choice.url, throwable, finalUserAction, + ", opened with " + choice.playerChoice)); + } + } + + public Consumer getResultHandler(final Choice choice) { + return info -> { + final String videoPlayerKey = getString(R.string.video_player_key); + final String backgroundPlayerKey = getString(R.string.background_player_key); + final String popupPlayerKey = getString(R.string.popup_player_key); + + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(this); + 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); + + PlayQueue playQueue; + String playerChoice = choice.playerChoice; + + if (info instanceof StreamInfo) { + if (playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) { + NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info); + + } else if (playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { + NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); + + } else { + playQueue = new SinglePlayQueue((StreamInfo) info); + + if (playerChoice.equals(videoPlayerKey)) { + NavigationHelper.playOnMainPlayer(this, playQueue, true); + } else if (playerChoice.equals(backgroundPlayerKey)) { + NavigationHelper.enqueueOnBackgroundPlayer(this, playQueue, true); + } else if (playerChoice.equals(popupPlayerKey)) { + NavigationHelper.enqueueOnPopupPlayer(this, playQueue, true); + } + } + } + + if (info instanceof ChannelInfo || info instanceof PlaylistInfo) { + playQueue = info instanceof ChannelInfo + ? new ChannelPlayQueue((ChannelInfo) info) + : new PlaylistPlayQueue((PlaylistInfo) info); + + if (playerChoice.equals(videoPlayerKey)) { + NavigationHelper.playOnMainPlayer(this, playQueue, true); + } else if (playerChoice.equals(backgroundPlayerKey)) { + NavigationHelper.playOnBackgroundPlayer(this, playQueue, true); + } else if (playerChoice.equals(popupPlayerKey)) { + NavigationHelper.playOnPopupPlayer(this, playQueue, true); + } + } + }; + } + + @Override + public void onDestroy() { + super.onDestroy(); + stopForeground(true); + if (fetcher != null) { + fetcher.dispose(); + } + } + + private NotificationCompat.Builder createNotification() { + return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle( + getString(R.string.preferred_player_fetcher_notification_title)) + .setContentText( + getString(R.string.preferred_player_fetcher_notification_message)); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + private String getUrl(final Intent intent) { + String foundUrl = null; + if (intent.getData() != null) { + // Called from another app + foundUrl = intent.getData().toString(); + } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) { + // Called from the share menu + final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT); + foundUrl = UrlFinder.firstUrlFromInput(extraText); + } + + return foundUrl; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/AboutActivity.java b/app/src/main/java/org/schabi/newpipelegacy/about/AboutActivity.java new file mode 100644 index 000000000..0fe60c53c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/AboutActivity.java @@ -0,0 +1,201 @@ +package org.schabi.newpipelegacy.about; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; +import androidx.fragment.app.FragmentStatePagerAdapter; +import androidx.viewpager.widget.PagerAdapter; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; +import static org.schabi.newpipelegacy.util.ShareUtils.openUrlInBrowser; + +public class AboutActivity extends AppCompatActivity { + /** + * List of all software components. + */ + private static final SoftwareComponent[] SOFTWARE_COMPONENTS = new SoftwareComponent[]{ + new SoftwareComponent("Giga Get", "2014 - 2015", "Peter Cai", + "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL2), + new SoftwareComponent("NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", + "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3), + new SoftwareComponent("Jsoup", "2017", "Jonathan Hedley", + "https://github.com/jhy/jsoup", StandardLicenses.MIT), + new SoftwareComponent("Rhino", "2015", "Mozilla", + "https://www.mozilla.org/rhino/", StandardLicenses.MPL2), + new SoftwareComponent("ACRA", "2013", "Kevin Gaudin", + "http://www.acra.ch", StandardLicenses.APACHE2), + new SoftwareComponent("Universal Image Loader", "2011 - 2015", "Sergey Tarasevich", + "https://github.com/nostra13/Android-Universal-Image-Loader", + StandardLicenses.APACHE2), + new SoftwareComponent("CircleImageView", "2014 - 2020", "Henning Dodenhof", + "https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2), + new SoftwareComponent("NoNonsense-FilePicker", "2016", "Jonas Kalderstam", + "https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2), + new SoftwareComponent("ExoPlayer", "2014 - 2020", "Google Inc", + "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2), + new SoftwareComponent("RxAndroid", "2015 - 2018", "The RxAndroid authors", + "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2), + new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", + "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2), + new SoftwareComponent("RxBinding", "2015 - 2018", "Jake Wharton", + "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2), + new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", + "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2), + new SoftwareComponent("Markwon", "2017 - 2020", "Noties", + "https://github.com/noties/Markwon", StandardLicenses.APACHE2), + new SoftwareComponent("Groupie", "2016", "Lisa Wray", + "https://github.com/lisawray/groupie", StandardLicenses.MIT) + }; + + /** + * The {@link PagerAdapter} that will provide + * fragments for each of the sections. We use a + * {@link FragmentPagerAdapter} derivative, which will keep every + * loaded fragment in memory. If this becomes too memory intensive, it + * may be best to switch to a + * {@link FragmentStatePagerAdapter}. + */ + private SectionsPagerAdapter mSectionsPagerAdapter; + + /** + * The {@link ViewPager} that will host the section contents. + */ + private ViewPager mViewPager; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceState); + ThemeHelper.setTheme(this); + this.setTitle(getString(R.string.title_activity_about)); + + setContentView(R.layout.activity_about); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + // Create the adapter that will return a fragment for each of the three + // primary sections of the activity. + mSectionsPagerAdapter = new SectionsPagerAdapter(getSupportFragmentManager()); + + // Set up the ViewPager with the sections adapter. + mViewPager = findViewById(R.id.container); + mViewPager.setAdapter(mSectionsPagerAdapter); + + TabLayout tabLayout = findViewById(R.id.tabs); + tabLayout.setupWithViewPager(mViewPager); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + + switch (id) { + case android.R.id.home: + finish(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /** + * A placeholder fragment containing a simple view. + */ + public static class AboutFragment extends Fragment { + public AboutFragment() { } + + /** + * Created a new instance of this fragment for the given section number. + * + * @return New instance of {@link AboutFragment} + */ + public static AboutFragment newInstance() { + return new AboutFragment(); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + View rootView = inflater.inflate(R.layout.fragment_about, container, false); + Context context = this.getContext(); + + TextView version = rootView.findViewById(R.id.app_version); + version.setText(BuildConfig.VERSION_NAME); + + View githubLink = rootView.findViewById(R.id.github_link); + githubLink.setOnClickListener(nv -> + openUrlInBrowser(context, context.getString(R.string.github_url))); + + View donationLink = rootView.findViewById(R.id.donation_link); + donationLink.setOnClickListener(v -> + openUrlInBrowser(context, context.getString(R.string.donation_url))); + + View websiteLink = rootView.findViewById(R.id.website_link); + websiteLink.setOnClickListener(nv -> + openUrlInBrowser(context, context.getString(R.string.website_url))); + + View privacyPolicyLink = rootView.findViewById(R.id.privacy_policy_link); + privacyPolicyLink.setOnClickListener(v -> + openUrlInBrowser(context, context.getString(R.string.privacy_policy_url))); + + return rootView; + } + + } + + /** + * A {@link FragmentPagerAdapter} that returns a fragment corresponding to + * one of the sections/tabs/pages. + */ + public class SectionsPagerAdapter extends FragmentPagerAdapter { + public SectionsPagerAdapter(final FragmentManager fm) { + super(fm); + } + + @Override + public Fragment getItem(final int position) { + switch (position) { + case 0: + return AboutFragment.newInstance(); + case 1: + return LicenseFragment.newInstance(SOFTWARE_COMPONENTS); + } + return null; + } + + @Override + public int getCount() { + // Show 2 total pages. + return 2; + } + + @Override + public CharSequence getPageTitle(final int position) { + switch (position) { + case 0: + return getString(R.string.tab_about); + case 1: + return getString(R.string.tab_licenses); + } + return null; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/License.java b/app/src/main/java/org/schabi/newpipelegacy/about/License.java new file mode 100644 index 000000000..38138a061 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/License.java @@ -0,0 +1,78 @@ +package org.schabi.newpipelegacy.about; + +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; + +/** + * Class for storing information about a software license. + */ +public class License implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public License createFromParcel(final Parcel source) { + return new License(source); + } + + @Override + public License[] newArray(final int size) { + return new License[size]; + } + }; + private final String abbreviation; + private final String name; + private String filename; + + public License(final String name, final String abbreviation, final String filename) { + if (name == null) { + throw new NullPointerException("name is null"); + } + if (abbreviation == null) { + throw new NullPointerException("abbreviation is null"); + } + if (filename == null) { + throw new NullPointerException("filename is null"); + } + this.name = name; + this.filename = filename; + this.abbreviation = abbreviation; + } + + protected License(final Parcel in) { + this.filename = in.readString(); + this.abbreviation = in.readString(); + this.name = in.readString(); + } + + public Uri getContentUri() { + return new Uri.Builder() + .scheme("file") + .path("/android_asset") + .appendPath(filename) + .build(); + } + + public String getAbbreviation() { + return abbreviation; + } + + public String getFilename() { + return filename; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(this.filename); + dest.writeString(this.abbreviation); + dest.writeString(this.name); + } + + public String getName() { + return name; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragment.java b/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragment.java new file mode 100644 index 000000000..2f08875ea --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragment.java @@ -0,0 +1,119 @@ +package org.schabi.newpipelegacy.about; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.LayoutInflater; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.ShareUtils; + +import java.util.Arrays; + +/** + * Fragment containing the software licenses. + */ +public class LicenseFragment extends Fragment { + private static final String ARG_COMPONENTS = "components"; + private SoftwareComponent[] softwareComponents; + private SoftwareComponent componentForContextMenu; + + public static LicenseFragment newInstance(final SoftwareComponent[] softwareComponents) { + if (softwareComponents == null) { + throw new NullPointerException("softwareComponents is null"); + } + LicenseFragment fragment = new LicenseFragment(); + Bundle bundle = new Bundle(); + bundle.putParcelableArray(ARG_COMPONENTS, softwareComponents); + fragment.setArguments(bundle); + return fragment; + } + + /** + * Shows a popup containing the license. + * + * @param context the context to use + * @param license the license to show + */ + private static void showLicense(final Context context, final License license) { + new LicenseFragmentHelper((Activity) context).execute(license); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + softwareComponents = (SoftwareComponent[]) getArguments() + .getParcelableArray(ARG_COMPONENTS); + + // Sort components by name + Arrays.sort(softwareComponents, (o1, o2) -> o1.getName().compareTo(o2.getName())); + } + + @Nullable + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + final View rootView = inflater.inflate(R.layout.fragment_licenses, container, false); + final ViewGroup softwareComponentsView = rootView.findViewById(R.id.software_components); + + final View licenseLink = rootView.findViewById(R.id.app_read_license); + licenseLink.setOnClickListener(v -> + showLicense(getActivity(), StandardLicenses.GPL3)); + + for (final SoftwareComponent component : softwareComponents) { + final View componentView = inflater + .inflate(R.layout.item_software_component, container, false); + final TextView softwareName = componentView.findViewById(R.id.name); + final TextView copyright = componentView.findViewById(R.id.copyright); + softwareName.setText(component.getName()); + copyright.setText(getString(R.string.copyright, + component.getYears(), + component.getCopyrightOwner(), + component.getLicense().getAbbreviation())); + + componentView.setTag(component); + componentView.setOnClickListener(v -> + showLicense(getActivity(), component.getLicense())); + softwareComponentsView.addView(componentView); + registerForContextMenu(componentView); + } + return rootView; + } + + @Override + public void onCreateContextMenu(final ContextMenu menu, final View v, + final ContextMenu.ContextMenuInfo menuInfo) { + final MenuInflater inflater = getActivity().getMenuInflater(); + final SoftwareComponent component = (SoftwareComponent) v.getTag(); + menu.setHeaderTitle(component.getName()); + inflater.inflate(R.menu.software_component, menu); + super.onCreateContextMenu(menu, v, menuInfo); + componentForContextMenu = (SoftwareComponent) v.getTag(); + } + + @Override + public boolean onContextItemSelected(final MenuItem item) { + // item.getMenuInfo() is null so we use the tag of the view + final SoftwareComponent component = componentForContextMenu; + if (component == null) { + return false; + } + switch (item.getItemId()) { + case R.id.action_website: + ShareUtils.openUrlInBrowser(getActivity(), component.getLink()); + return true; + case R.id.action_show_license: + showLicense(getActivity(), component.getLicense()); + } + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragmentHelper.java b/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragmentHelper.java new file mode 100644 index 000000000..b1c9bf558 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/LicenseFragmentHelper.java @@ -0,0 +1,128 @@ +package org.schabi.newpipelegacy.about; + +import android.app.Activity; +import android.content.Context; +import android.os.AsyncTask; +import android.util.Base64; +import android.webkit.WebView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.lang.ref.WeakReference; +import java.nio.charset.StandardCharsets; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class LicenseFragmentHelper extends AsyncTask { + private final WeakReference weakReference; + private License license; + + public LicenseFragmentHelper(@Nullable final Activity activity) { + weakReference = new WeakReference<>(activity); + } + + /** + * @param context the context to use + * @param license the license + * @return String which contains a HTML formatted license page + * styled according to the context's theme + */ + private static String getFormattedLicense(@NonNull final Context context, + @NonNull final License license) { + final StringBuilder licenseContent = new StringBuilder(); + final String webViewData; + try { + final BufferedReader in = new BufferedReader(new InputStreamReader( + context.getAssets().open(license.getFilename()), StandardCharsets.UTF_8)); + String str; + while ((str = in.readLine()) != null) { + licenseContent.append(str); + } + in.close(); + + // split the HTML file and insert the stylesheet into the HEAD of the file + webViewData = licenseContent.toString().replace("", + ""); + } catch (IOException e) { + throw new IllegalArgumentException( + "Could not get license file: " + license.getFilename(), e); + } + return webViewData; + } + + /** + * @param context + * @return String which is a CSS stylesheet according to the context's theme + */ + private static String getLicenseStylesheet(final Context context) { + final boolean isLightTheme = ThemeHelper.isLightThemeSelected(context); + return "body{padding:12px 15px;margin:0;" + + "background:#" + getHexRGBColor(context, isLightTheme + ? R.color.light_license_background_color + : R.color.dark_license_background_color) + ";" + + "color:#" + getHexRGBColor(context, isLightTheme + ? R.color.light_license_text_color + : R.color.dark_license_text_color) + "}" + + "a[href]{color:#" + getHexRGBColor(context, isLightTheme + ? R.color.light_youtube_primary_color + : R.color.dark_youtube_primary_color) + "}" + + "pre{white-space:pre-wrap}"; + } + + /** + * Cast R.color to a hexadecimal color value. + * + * @param context the context to use + * @param color the color number from R.color + * @return a six characters long String with hexadecimal RGB values + */ + private static String getHexRGBColor(final Context context, final int color) { + return context.getResources().getString(color).substring(3); + } + + @Nullable + private Activity getActivity() { + final Activity activity = weakReference.get(); + + if (activity != null && activity.isFinishing()) { + return null; + } else { + return activity; + } + } + + @Override + protected Integer doInBackground(final Object... objects) { + license = (License) objects[0]; + return 1; + } + + @Override + protected void onPostExecute(final Integer result) { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + final String webViewData = Base64.encodeToString(getFormattedLicense(activity, license) + .getBytes(StandardCharsets.UTF_8), Base64.NO_PADDING); + final WebView webView = new WebView(activity); + webView.loadData(webViewData, "text/html; charset=UTF-8", "base64"); + + final AlertDialog.Builder alert = new AlertDialog.Builder(activity); + alert.setTitle(license.getName()); + alert.setView(webView); + assureCorrectAppLanguage(activity); + alert.setNegativeButton(activity.getString(R.string.finish), + (dialog, which) -> dialog.dismiss()); + alert.show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/SoftwareComponent.java b/app/src/main/java/org/schabi/newpipelegacy/about/SoftwareComponent.java new file mode 100644 index 000000000..bda821d9d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/SoftwareComponent.java @@ -0,0 +1,83 @@ +package org.schabi.newpipelegacy.about; + +import android.os.Parcel; +import android.os.Parcelable; + +public class SoftwareComponent implements Parcelable { + public static final Creator CREATOR = new Creator() { + @Override + public SoftwareComponent createFromParcel(final Parcel source) { + return new SoftwareComponent(source); + } + + @Override + public SoftwareComponent[] newArray(final int size) { + return new SoftwareComponent[size]; + } + }; + + private final License license; + private final String name; + private final String years; + private final String copyrightOwner; + private final String link; + private final String version; + + public SoftwareComponent(final String name, final String years, final String copyrightOwner, + final String link, final License license) { + this.name = name; + this.years = years; + this.copyrightOwner = copyrightOwner; + this.link = link; + this.license = license; + this.version = null; + } + + protected SoftwareComponent(final Parcel in) { + this.name = in.readString(); + this.license = in.readParcelable(License.class.getClassLoader()); + this.copyrightOwner = in.readString(); + this.link = in.readString(); + this.years = in.readString(); + this.version = in.readString(); + } + + public String getName() { + return name; + } + + public String getYears() { + return years; + } + + public String getCopyrightOwner() { + return copyrightOwner; + } + + public String getLink() { + return link; + } + + public String getVersion() { + return version; + } + + public License getLicense() { + return license; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(name); + dest.writeParcelable(license, flags); + dest.writeString(copyrightOwner); + dest.writeString(link); + dest.writeString(years); + dest.writeString(version); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/about/StandardLicenses.java b/app/src/main/java/org/schabi/newpipelegacy/about/StandardLicenses.java new file mode 100644 index 000000000..a548089dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/about/StandardLicenses.java @@ -0,0 +1,19 @@ +package org.schabi.newpipelegacy.about; + +/** + * Class containing information about standard software licenses. + */ +public final class StandardLicenses { + public static final License GPL2 + = new License("GNU General Public License, Version 2.0", "GPLv2", "gpl_2.html"); + public static final License GPL3 + = new License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html"); + public static final License APACHE2 + = new License("Apache License, Version 2.0", "ALv2", "apache2.html"); + public static final License MPL2 + = new License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html"); + public static final License MIT + = new License("MIT License", "MIT", "mit.html"); + + private StandardLicenses() { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipelegacy/database/AppDatabase.java new file mode 100644 index 000000000..8fdb4de7e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/AppDatabase.java @@ -0,0 +1,65 @@ +package org.schabi.newpipelegacy.database; + +import androidx.room.Database; +import androidx.room.RoomDatabase; +import androidx.room.TypeConverters; + +import org.schabi.newpipelegacy.database.feed.dao.FeedDAO; +import org.schabi.newpipelegacy.database.feed.dao.FeedGroupDAO; +import org.schabi.newpipelegacy.database.feed.model.FeedEntity; +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity; +import org.schabi.newpipelegacy.database.feed.model.FeedGroupSubscriptionEntity; +import org.schabi.newpipelegacy.database.feed.model.FeedLastUpdatedEntity; +import org.schabi.newpipelegacy.database.history.dao.SearchHistoryDAO; +import org.schabi.newpipelegacy.database.history.dao.StreamHistoryDAO; +import org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry; +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistDAO; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistRemoteDAO; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistStreamDAO; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipelegacy.database.stream.dao.StreamDAO; +import org.schabi.newpipelegacy.database.stream.dao.StreamStateDAO; +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipelegacy.database.subscription.SubscriptionDAO; +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity; + +import static org.schabi.newpipelegacy.database.Migrations.DB_VER_3; + +@TypeConverters({Converters.class}) +@Database( + entities = { + SubscriptionEntity.class, SearchHistoryEntry.class, + StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class, + PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class, + FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, + FeedLastUpdatedEntity.class + }, + version = DB_VER_3 +) +public abstract class AppDatabase extends RoomDatabase { + public static final String DATABASE_NAME = "newpipe.db"; + + public abstract SearchHistoryDAO searchHistoryDAO(); + + public abstract StreamDAO streamDAO(); + + public abstract StreamHistoryDAO streamHistoryDAO(); + + public abstract StreamStateDAO streamStateDAO(); + + public abstract PlaylistDAO playlistDAO(); + + public abstract PlaylistStreamDAO playlistStreamDAO(); + + public abstract PlaylistRemoteDAO playlistRemoteDAO(); + + public abstract FeedDAO feedDAO(); + + public abstract FeedGroupDAO feedGroupDAO(); + + public abstract SubscriptionDAO subscriptionDAO(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/BasicDAO.java new file mode 100644 index 000000000..4a06647df --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/BasicDAO.java @@ -0,0 +1,46 @@ +package org.schabi.newpipelegacy.database; + +import androidx.room.Dao; +import androidx.room.Delete; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Update; + +import java.util.Collection; +import java.util.List; + +import io.reactivex.Flowable; + +@Dao +public interface BasicDAO { + /* Inserts */ + @Insert(onConflict = OnConflictStrategy.FAIL) + long insert(Entity entity); + + @Insert(onConflict = OnConflictStrategy.FAIL) + List insertAll(Entity... entities); + + @Insert(onConflict = OnConflictStrategy.FAIL) + List insertAll(Collection entities); + + /* Searches */ + Flowable> getAll(); + + Flowable> listByService(int serviceId); + + /* Deletes */ + @Delete + void delete(Entity entity); + + @Delete + int delete(Collection entities); + + int deleteAll(); + + /* Updates */ + @Update + int update(Entity entity); + + @Update + void update(Collection entities); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/Converters.java b/app/src/main/java/org/schabi/newpipelegacy/database/Converters.java new file mode 100644 index 000000000..1494a8d65 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/Converters.java @@ -0,0 +1,60 @@ +package org.schabi.newpipelegacy.database; + +import androidx.room.TypeConverter; + +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon; + +import java.util.Date; + +public final class Converters { + private Converters() { } + + /** + * Convert a long value to a date. + * + * @param value the long value + * @return the date + */ + @TypeConverter + public static Date fromTimestamp(final Long value) { + return value == null ? null : new Date(value); + } + + /** + * Convert a date to a long value. + * + * @param date the date + * @return the long value + */ + @TypeConverter + public static Long dateToTimestamp(final Date date) { + return date == null ? null : date.getTime(); + } + + @TypeConverter + public static StreamType streamTypeOf(final String value) { + return StreamType.valueOf(value); + } + + @TypeConverter + public static String stringOf(final StreamType streamType) { + return streamType.name(); + } + + @TypeConverter + public static Integer integerOf(final FeedGroupIcon feedGroupIcon) { + return feedGroupIcon.getId(); + } + + @TypeConverter + public static FeedGroupIcon feedGroupIconOf(final Integer id) { + for (FeedGroupIcon icon : FeedGroupIcon.values()) { + if (icon.getId() == id) { + return icon; + } + } + + throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\""); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/LocalItem.java b/app/src/main/java/org/schabi/newpipelegacy/database/LocalItem.java new file mode 100644 index 000000000..99737af57 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/LocalItem.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.database; + +public interface LocalItem { + LocalItemType getLocalItemType(); + + enum LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM, + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/Migrations.java b/app/src/main/java/org/schabi/newpipelegacy/database/Migrations.java new file mode 100644 index 000000000..ea2bf6929 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/Migrations.java @@ -0,0 +1,164 @@ +package org.schabi.newpipelegacy.database; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import org.schabi.newpipelegacy.BuildConfig; + +public final class Migrations { + public static final int DB_VER_1 = 1; + public static final int DB_VER_2 = 2; + public static final int DB_VER_3 = 3; + + private static final String TAG = Migrations.class.getName(); + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + + public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + if (DEBUG) { + Log.d(TAG, "Start migrating database"); + } + /* + * Unfortunately these queries must be hardcoded due to the possibility of + * schema and names changing at a later date, thus invalidating the older migration + * scripts if they are not hardcoded. + * */ + + // Not much we can do about this, since room doesn't create tables before migration. + // It's either this or blasting the entire database anew. + database.execSQL("CREATE INDEX `index_search_history_search` " + + "ON `search_history` (`search`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `streams` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " + + "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " + + "`thumbnail_url` TEXT)"); + database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` " + + "ON `streams` (`service_id`, `url`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` " + + "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " + + "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " + + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + + "ON UPDATE CASCADE ON DELETE CASCADE )"); + database.execSQL("CREATE INDEX `index_stream_history_stream_id` " + + "ON `stream_history` (`stream_id`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` " + + "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " + + "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " + + "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"); + database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`name` TEXT, `thumbnail_url` TEXT)"); + database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` " + + "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " + + "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " + + "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE UNIQUE INDEX " + + "`index_playlist_stream_join_playlist_id_join_index` " + + "ON `playlist_stream_join` (`playlist_id`, `join_index`)"); + database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` " + + "ON `playlist_stream_join` (`stream_id`)"); + database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` " + + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"); + database.execSQL("CREATE INDEX `index_remote_playlists_name` " + + "ON `remote_playlists` (`name`)"); + database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + + "ON `remote_playlists` (`service_id`, `url`)"); + + // Populate streams table with existing entries in watch history + // Latest data first, thus ignoring older entries with the same indices + database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, " + + "stream_type, duration, uploader, thumbnail_url) " + + + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " + + "uploader, thumbnail_url " + + + "FROM watch_history " + + "ORDER BY creation_date DESC"); + + // Once the streams have PKs, join them with the normalized history table + // and populate it with the remaining data from watch history + database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)" + + "SELECT uid, creation_date, 1 " + + "FROM watch_history INNER JOIN streams " + + "ON watch_history.service_id == streams.service_id " + + "AND watch_history.url == streams.url " + + "ORDER BY creation_date DESC"); + + database.execSQL("DROP TABLE IF EXISTS watch_history"); + + if (DEBUG) { + Log.d(TAG, "Stop migrating database"); + } + } + }; + + public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + // Add NOT NULLs and new fields + database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " + + "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " + + "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " + + "textual_upload_date TEXT, upload_date INTEGER, " + + "is_upload_date_approximation INTEGER)"); + + database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, " + + "duration, uploader, thumbnail_url, view_count, textual_upload_date, " + + "upload_date, is_upload_date_approximation) " + + + "SELECT uid, service_id, url, ifnull(title, ''), " + + "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " + + "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " + + + "FROM streams WHERE url IS NOT NULL"); + + database.execSQL("DROP TABLE streams"); + database.execSQL("ALTER TABLE streams_new RENAME TO streams"); + database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url " + + "ON streams (service_id, url)"); + + // Tables for feed feature + database.execSQL("CREATE TABLE IF NOT EXISTS feed " + + "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + + "PRIMARY KEY(stream_id, subscription_id), " + + "FOREIGN KEY(stream_id) REFERENCES streams(uid) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_group " + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " + + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"); + database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join " + + "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + + "PRIMARY KEY(group_id, subscription_id), " + + "FOREIGN KEY(group_id) REFERENCES feed_group(uid) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id " + + "ON feed_group_subscription_join (subscription_id)"); + database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated " + + "(subscription_id INTEGER NOT NULL, last_updated INTEGER, " + + "PRIMARY KEY(subscription_id), " + + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"); + } + }; + + private Migrations() { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedDAO.kt new file mode 100644 index 000000000..3a6efc671 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedDAO.kt @@ -0,0 +1,152 @@ +package org.schabi.newpipelegacy.database.feed.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.reactivex.Flowable +import java.util.Date +import org.schabi.newpipelegacy.database.feed.model.FeedEntity +import org.schabi.newpipelegacy.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipelegacy.database.stream.model.StreamEntity +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity + +@Dao +abstract class FeedDAO { + @Query("DELETE FROM feed") + abstract fun deleteAll(): Int + + @Query(""" + SELECT s.* FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + + LIMIT 500 + """) + abstract fun getAllStreams(): Flowable> + + @Query(""" + SELECT s.* FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = f.subscription_id + + INNER JOIN feed_group fg + ON fg.uid = fgs.group_id + + WHERE fgs.group_id = :groupId + + ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC + LIMIT 500 + """) + abstract fun getAllStreamsFromGroup(groupId: Long): Flowable> + + @Query(""" + DELETE FROM feed WHERE + + feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.upload_date < :date + ) + """) + abstract fun unlinkStreamsOlderThan(date: Date) + + @Query(""" + DELETE FROM feed + + WHERE feed.subscription_id = :subscriptionId + + AND feed.stream_id IN ( + SELECT s.uid FROM streams s + + INNER JOIN feed f + ON s.uid = f.stream_id + + WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM" + ) + """) + abstract fun unlinkOldLivestreams(subscriptionId: Long) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insert(feedEntity: FeedEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insertAll(entities: List): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity) + + @Transaction + open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) { + val id = insertLastUpdated(lastUpdatedEntity) + + if (id == -1L) { + updateLastUpdated(lastUpdatedEntity) + } + } + + @Query(""" + SELECT MIN(lu.last_updated) FROM feed_last_updated lu + + INNER JOIN feed_group_subscription_join fgs + ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId + """) + abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> + + @Query("SELECT MIN(last_updated) FROM feed_last_updated") + abstract fun oldestSubscriptionUpdateFromAll(): Flowable> + + @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL") + abstract fun notLoadedCount(): Flowable + + @Query(""" + SELECT COUNT(*) FROM subscriptions s + + INNER JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL + """) + abstract fun notLoadedCountForGroup(groupId: Long): Flowable + + @Query(""" + SELECT s.* FROM subscriptions s + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold + """) + abstract fun getAllOutdated(outdatedThreshold: Date): Flowable> + + @Query(""" + SELECT s.* FROM subscriptions s + + INNER JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId + + LEFT JOIN feed_last_updated lu + ON s.uid = lu.subscription_id + + WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold + """) + abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: Date): Flowable> +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedGroupDAO.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedGroupDAO.kt new file mode 100644 index 000000000..800640291 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/dao/FeedGroupDAO.kt @@ -0,0 +1,67 @@ +package org.schabi.newpipelegacy.database.feed.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import io.reactivex.Flowable +import io.reactivex.Maybe +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.database.feed.model.FeedGroupSubscriptionEntity + +@Dao +abstract class FeedGroupDAO { + + @Query("SELECT * FROM feed_group ORDER BY sort_order ASC") + abstract fun getAll(): Flowable> + + @Query("SELECT * FROM feed_group WHERE uid = :groupId") + abstract fun getGroup(groupId: Long): Maybe + + @Transaction + open fun insert(feedGroupEntity: FeedGroupEntity): Long { + val nextSortOrder = nextSortOrder() + feedGroupEntity.sortOrder = nextSortOrder + return insertInternal(feedGroupEntity) + } + + @Update(onConflict = OnConflictStrategy.IGNORE) + abstract fun update(feedGroupEntity: FeedGroupEntity): Int + + @Query("DELETE FROM feed_group") + abstract fun deleteAll(): Int + + @Query("DELETE FROM feed_group WHERE uid = :groupId") + abstract fun delete(groupId: Long): Int + + @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> + + @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") + abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract fun insertSubscriptionsToGroup(entities: List): List + + @Transaction + open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) { + deleteSubscriptionsFromGroup(groupId) + insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) }) + } + + @Transaction + open fun updateOrder(orderMap: Map) { + orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) } + } + + @Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId") + abstract fun updateOrder(groupId: Long, sortOrder: Long): Int + + @Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group") + protected abstract fun nextSortOrder(): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedEntity.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedEntity.kt new file mode 100644 index 000000000..7271115e3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedEntity.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipelegacy.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.schabi.newpipelegacy.database.feed.model.FeedEntity.Companion.FEED_TABLE +import org.schabi.newpipelegacy.database.feed.model.FeedEntity.Companion.STREAM_ID +import org.schabi.newpipelegacy.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipelegacy.database.stream.model.StreamEntity +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity + +@Entity(tableName = FEED_TABLE, + primaryKeys = [STREAM_ID, SUBSCRIPTION_ID], + indices = [Index(SUBSCRIPTION_ID)], + foreignKeys = [ + ForeignKey( + entity = StreamEntity::class, + parentColumns = [StreamEntity.STREAM_ID], + childColumns = [STREAM_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true), + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true) + ] +) +data class FeedEntity( + @ColumnInfo(name = STREAM_ID) + var streamId: Long, + + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long +) { + + companion object { + const val FEED_TABLE = "feed" + + const val STREAM_ID = "stream_id" + const val SUBSCRIPTION_ID = "subscription_id" + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupEntity.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupEntity.kt new file mode 100644 index 000000000..59559ff89 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupEntity.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipelegacy.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon + +@Entity( + tableName = FEED_GROUP_TABLE, + indices = [Index(SORT_ORDER)] +) +data class FeedGroupEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = ID) + val uid: Long, + + @ColumnInfo(name = NAME) + var name: String, + + @ColumnInfo(name = ICON) + var icon: FeedGroupIcon, + + @ColumnInfo(name = SORT_ORDER) + var sortOrder: Long = -1 +) { + companion object { + const val FEED_GROUP_TABLE = "feed_group" + + const val ID = "uid" + const val NAME = "name" + const val ICON = "icon_id" + const val SORT_ORDER = "sort_order" + + const val GROUP_ALL_ID = -1L + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupSubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupSubscriptionEntity.kt new file mode 100644 index 000000000..1b3d5b8f9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedGroupSubscriptionEntity.kt @@ -0,0 +1,45 @@ +package org.schabi.newpipelegacy.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.ForeignKey.CASCADE +import androidx.room.Index +import org.schabi.newpipelegacy.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE +import org.schabi.newpipelegacy.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID +import org.schabi.newpipelegacy.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity + +@Entity( + tableName = FEED_GROUP_SUBSCRIPTION_TABLE, + primaryKeys = [GROUP_ID, SUBSCRIPTION_ID], + indices = [Index(SUBSCRIPTION_ID)], + foreignKeys = [ + ForeignKey( + entity = FeedGroupEntity::class, + parentColumns = [FeedGroupEntity.ID], + childColumns = [GROUP_ID], + onDelete = CASCADE, onUpdate = CASCADE, deferred = true), + + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = CASCADE, onUpdate = CASCADE, deferred = true) + ] +) +data class FeedGroupSubscriptionEntity( + @ColumnInfo(name = GROUP_ID) + var feedGroupId: Long, + + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long +) { + + companion object { + const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join" + + const val GROUP_ID = "group_id" + const val SUBSCRIPTION_ID = "subscription_id" + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedLastUpdatedEntity.kt b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedLastUpdatedEntity.kt new file mode 100644 index 000000000..842fa8633 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/feed/model/FeedLastUpdatedEntity.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipelegacy.database.feed.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.util.Date +import org.schabi.newpipelegacy.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE +import org.schabi.newpipelegacy.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity + +@Entity( + tableName = FEED_LAST_UPDATED_TABLE, + foreignKeys = [ + ForeignKey( + entity = SubscriptionEntity::class, + parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], + childColumns = [SUBSCRIPTION_ID], + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true) + ] +) +data class FeedLastUpdatedEntity( + @PrimaryKey + @ColumnInfo(name = SUBSCRIPTION_ID) + var subscriptionId: Long, + + @ColumnInfo(name = LAST_UPDATED) + var lastUpdated: Date? = null +) { + + companion object { + const val FEED_LAST_UPDATED_TABLE = "feed_last_updated" + + const val SUBSCRIPTION_ID = "subscription_id" + const val LAST_UPDATED = "last_updated" + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/HistoryDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/HistoryDAO.java new file mode 100644 index 000000000..41756fb57 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/HistoryDAO.java @@ -0,0 +1,7 @@ +package org.schabi.newpipelegacy.database.history.dao; + +import org.schabi.newpipelegacy.database.BasicDAO; + +public interface HistoryDAO extends BasicDAO { + T getLatestEntry(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/SearchHistoryDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/SearchHistoryDAO.java new file mode 100644 index 000000000..42dd88634 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/SearchHistoryDAO.java @@ -0,0 +1,51 @@ +package org.schabi.newpipelegacy.database.history.dao; + +import androidx.annotation.Nullable; +import androidx.room.Dao; +import androidx.room.Query; + +import org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.CREATION_DATE; +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.ID; +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.SEARCH; +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.SERVICE_ID; +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.TABLE_NAME; + +@Dao +public interface SearchHistoryDAO extends HistoryDAO { + String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC"; + + @Query("SELECT * FROM " + TABLE_NAME + + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") + @Nullable + SearchHistoryEntry getLatestEntry(); + + @Query("DELETE FROM " + TABLE_NAME) + @Override + int deleteAll(); + + @Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query") + int deleteAllWhereQuery(String query); + + @Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE) + @Override + Flowable> getAll(); + + @Query("SELECT * FROM " + TABLE_NAME + " GROUP BY " + SEARCH + ORDER_BY_CREATION_DATE + + " LIMIT :limit") + Flowable> getUniqueEntries(int limit); + + @Query("SELECT * FROM " + TABLE_NAME + + " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE) + @Override + Flowable> listByService(int serviceId); + + @Query("SELECT * FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'" + + " GROUP BY " + SEARCH + " LIMIT :limit") + Flowable> getSimilarEntries(String query, int limit); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/StreamHistoryDAO.java new file mode 100644 index 000000000..2b48d0ddf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/dao/StreamHistoryDAO.java @@ -0,0 +1,78 @@ +package org.schabi.newpipelegacy.database.history.dao; + +import androidx.annotation.Nullable; +import androidx.room.Dao; +import androidx.room.Query; + +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity; +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntry; +import org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT; +import static org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE; +import static org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT; +import static org.schabi.newpipelegacy.database.stream.model.StreamEntity.STREAM_ID; +import static org.schabi.newpipelegacy.database.stream.model.StreamEntity.STREAM_TABLE; + +@Dao +public abstract class StreamHistoryDAO implements HistoryDAO { + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + + " WHERE " + STREAM_ACCESS_DATE + " = " + + "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")") + @Override + @Nullable + public abstract StreamHistoryEntity getLatestEntry(); + + @Override + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + STREAM_HISTORY_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(final int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + STREAM_TABLE + + " INNER JOIN " + STREAM_HISTORY_TABLE + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " ORDER BY " + STREAM_ACCESS_DATE + " DESC") + public abstract Flowable> getHistory(); + + + @Query("SELECT * FROM " + STREAM_TABLE + + " INNER JOIN " + STREAM_HISTORY_TABLE + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " ORDER BY " + STREAM_ID + " ASC") + public abstract Flowable> getHistorySortedById(); + + @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + + " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1") + @Nullable + public abstract StreamHistoryEntity getLatestEntry(long streamId); + + @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract int deleteStreamHistory(long streamId); + + @Query("SELECT * FROM " + STREAM_TABLE + + // Select the latest entry and watch count for each stream id on history table + + " INNER JOIN " + + "(SELECT " + JOIN_STREAM_ID + ", " + + " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", " + + " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT + + " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")" + + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID) + public abstract Flowable> getStatistics(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/model/SearchHistoryEntry.java b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/SearchHistoryEntry.java new file mode 100644 index 000000000..073c13000 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/SearchHistoryEntry.java @@ -0,0 +1,78 @@ +package org.schabi.newpipelegacy.database.history.model; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import java.util.Date; + +import static org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry.SEARCH; + +@Entity(tableName = SearchHistoryEntry.TABLE_NAME, + indices = {@Index(value = SEARCH)}) +public class SearchHistoryEntry { + public static final String ID = "id"; + public static final String TABLE_NAME = "search_history"; + public static final String SERVICE_ID = "service_id"; + public static final String CREATION_DATE = "creation_date"; + public static final String SEARCH = "search"; + + @ColumnInfo(name = ID) + @PrimaryKey(autoGenerate = true) + private long id; + + @ColumnInfo(name = CREATION_DATE) + private Date creationDate; + + @ColumnInfo(name = SERVICE_ID) + private int serviceId; + + @ColumnInfo(name = SEARCH) + private String search; + + public SearchHistoryEntry(final Date creationDate, final int serviceId, final String search) { + this.serviceId = serviceId; + this.creationDate = creationDate; + this.search = search; + } + + public long getId() { + return id; + } + + public void setId(final long id) { + this.id = id; + } + + public Date getCreationDate() { + return creationDate; + } + + public void setCreationDate(final Date creationDate) { + this.creationDate = creationDate; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(final int serviceId) { + this.serviceId = serviceId; + } + + public String getSearch() { + return search; + } + + public void setSearch(final String search) { + this.search = search; + } + + @Ignore + public boolean hasEqualValues(final SearchHistoryEntry otherEntry) { + return getServiceId() == otherEntry.getServiceId() + && getSearch().equals(otherEntry.getSearch()); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntity.java new file mode 100644 index 000000000..cd52d5965 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntity.java @@ -0,0 +1,80 @@ +package org.schabi.newpipelegacy.database.history.model; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Ignore; +import androidx.room.Index; + +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; + +import java.util.Date; + +import static androidx.room.ForeignKey.CASCADE; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; +import static org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; + +@Entity(tableName = STREAM_HISTORY_TABLE, + primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE}, + // No need to index for timestamp as they will almost always be unique + indices = {@Index(value = {JOIN_STREAM_ID})}, + foreignKeys = { + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE) + }) +public class StreamHistoryEntity { + public static final String STREAM_HISTORY_TABLE = "stream_history"; + public static final String JOIN_STREAM_ID = "stream_id"; + public static final String STREAM_ACCESS_DATE = "access_date"; + public static final String STREAM_REPEAT_COUNT = "repeat_count"; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @NonNull + @ColumnInfo(name = STREAM_ACCESS_DATE) + private Date accessDate; + + @ColumnInfo(name = STREAM_REPEAT_COUNT) + private long repeatCount; + + public StreamHistoryEntity(final long streamUid, @NonNull final Date accessDate, + final long repeatCount) { + this.streamUid = streamUid; + this.accessDate = accessDate; + this.repeatCount = repeatCount; + } + + @Ignore + public StreamHistoryEntity(final long streamUid, @NonNull final Date accessDate) { + this(streamUid, accessDate, 1); + } + + public long getStreamUid() { + return streamUid; + } + + public void setStreamUid(final long streamUid) { + this.streamUid = streamUid; + } + + public Date getAccessDate() { + return accessDate; + } + + public void setAccessDate(@NonNull final Date accessDate) { + this.accessDate = accessDate; + } + + public long getRepeatCount() { + return repeatCount; + } + + public void setRepeatCount(final long repeatCount) { + this.repeatCount = repeatCount; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntry.kt new file mode 100644 index 000000000..71fad2766 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/history/model/StreamHistoryEntry.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipelegacy.database.history.model + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import java.util.Date +import org.schabi.newpipelegacy.database.stream.model.StreamEntity + +data class StreamHistoryEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) + val accessDate: Date, + + @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) + val repeatCount: Long +) { + + fun toStreamHistoryEntity(): StreamHistoryEntity { + return StreamHistoryEntity(streamId, accessDate, repeatCount) + } + + fun hasEqualValues(other: StreamHistoryEntry): Boolean { + return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && + accessDate.compareTo(other.accessDate) == 0 + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistLocalItem.java new file mode 100644 index 000000000..62dedf568 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistLocalItem.java @@ -0,0 +1,33 @@ +package org.schabi.newpipelegacy.database.playlist; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public interface PlaylistLocalItem extends LocalItem { + String getOrderingName(); + + static List merge( + final List localPlaylists, + final List remotePlaylists) { + final List items = new ArrayList<>( + localPlaylists.size() + remotePlaylists.size()); + items.addAll(localPlaylists); + items.addAll(remotePlaylists); + + Collections.sort(items, (left, right) -> { + final String on1 = left.getOrderingName(); + final String on2 = right.getOrderingName(); + if (on1 == null) { + return on2 == null ? 0 : 1; + } else { + return on2 == null ? -1 : on1.compareToIgnoreCase(on2); + } + }); + + return items; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistMetadataEntry.java new file mode 100644 index 000000000..748486658 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistMetadataEntry.java @@ -0,0 +1,38 @@ +package org.schabi.newpipelegacy.database.playlist; + +import androidx.room.ColumnInfo; + +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; + +public class PlaylistMetadataEntry implements PlaylistLocalItem { + public static final String PLAYLIST_STREAM_COUNT = "streamCount"; + + @ColumnInfo(name = PLAYLIST_ID) + public final long uid; + @ColumnInfo(name = PLAYLIST_NAME) + public final String name; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) + public final String thumbnailUrl; + @ColumnInfo(name = PLAYLIST_STREAM_COUNT) + public final long streamCount; + + public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, + final long streamCount) { + this.uid = uid; + this.name = name; + this.thumbnailUrl = thumbnailUrl; + this.streamCount = streamCount; + } + + @Override + public LocalItemType getLocalItemType() { + return LocalItemType.PLAYLIST_LOCAL_ITEM; + } + + @Override + public String getOrderingName() { + return name; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistStreamEntry.kt new file mode 100644 index 000000000..506012cfe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/PlaylistStreamEntry.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipelegacy.database.playlist + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipelegacy.database.LocalItem +import org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipelegacy.database.stream.model.StreamEntity + +class PlaylistStreamEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) + val joinIndex: Int +) : LocalItem { + + @Throws(IllegalArgumentException::class) + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + item.duration = streamEntity.duration + item.uploaderName = streamEntity.uploader + item.thumbnailUrl = streamEntity.thumbnailUrl + + return item + } + + override fun getLocalItemType(): LocalItem.LocalItemType { + return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistDAO.java new file mode 100644 index 000000000..cafd3d612 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistDAO.java @@ -0,0 +1,36 @@ +package org.schabi.newpipelegacy.database.playlist.dao; + +import androidx.room.Dao; +import androidx.room.Query; + +import org.schabi.newpipelegacy.database.BasicDAO; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; + +@Dao +public abstract class PlaylistDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + PLAYLIST_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + PLAYLIST_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(final int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") + public abstract Flowable> getPlaylist(long playlistId); + + @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") + public abstract int deletePlaylist(long playlistId); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistRemoteDAO.java new file mode 100644 index 000000000..ef9e69675 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistRemoteDAO.java @@ -0,0 +1,59 @@ +package org.schabi.newpipelegacy.database.playlist.dao; + +import androidx.room.Dao; +import androidx.room.Query; +import androidx.room.Transaction; + +import org.schabi.newpipelegacy.database.BasicDAO; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; + +@Dao +public abstract class PlaylistRemoteDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) + public abstract int deleteAll(); + + @Override + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + public abstract Flowable> listByService(int serviceId); + + @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + public abstract Flowable> getPlaylist(long serviceId, String url); + + @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + abstract Long getPlaylistIdInternal(long serviceId, String url); + + @Transaction + public long upsert(final PlaylistRemoteEntity playlist) { + final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); + + if (playlistId == null) { + return insert(playlist); + } else { + playlist.setUid(playlistId); + update(playlist); + return playlistId; + } + } + + @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE + + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") + public abstract int deletePlaylist(long playlistId); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistStreamDAO.java new file mode 100644 index 000000000..3d560cc7a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/dao/PlaylistStreamDAO.java @@ -0,0 +1,74 @@ +package org.schabi.newpipelegacy.database.playlist.dao; + +import androidx.room.Dao; +import androidx.room.Query; +import androidx.room.Transaction; + +import org.schabi.newpipelegacy.database.BasicDAO; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; +import static org.schabi.newpipelegacy.database.stream.model.StreamEntity.STREAM_ID; +import static org.schabi.newpipelegacy.database.stream.model.StreamEntity.STREAM_TABLE; + +@Dao +public abstract class PlaylistStreamDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(final int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + public abstract void deleteBatch(long playlistId); + + @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" + + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") + public abstract Flowable getMaximumIndexOf(long playlistId); + + @Transaction + @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " + // get ids of streams of the given playlist + + "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX + + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" + + // then merge with the stream metadata + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " ORDER BY " + JOIN_INDEX + " ASC") + public abstract Flowable> getOrderedStreamsOf(long playlistId); + + @Transaction + @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " + + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + + + " FROM " + PLAYLIST_TABLE + + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + + " GROUP BY " + JOIN_PLAYLIST_ID + + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + public abstract Flowable> getPlaylistMetadata(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistEntity.java new file mode 100644 index 000000000..fd8e9578e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistEntity.java @@ -0,0 +1,57 @@ +package org.schabi.newpipelegacy.database.playlist.model; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; + +@Entity(tableName = PLAYLIST_TABLE, + indices = {@Index(value = {PLAYLIST_NAME})}) +public class PlaylistEntity { + public static final String PLAYLIST_TABLE = "playlists"; + public static final String PLAYLIST_ID = "uid"; + public static final String PLAYLIST_NAME = "name"; + public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = PLAYLIST_ID) + private long uid = 0; + + @ColumnInfo(name = PLAYLIST_NAME) + private String name; + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) + private String thumbnailUrl; + + public PlaylistEntity(final String name, final String thumbnailUrl) { + this.name = name; + this.thumbnailUrl = thumbnailUrl; + } + + public long getUid() { + return uid; + } + + public void setUid(final long uid) { + this.uid = uid; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(final String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistRemoteEntity.java new file mode 100644 index 000000000..6b20cb49d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistRemoteEntity.java @@ -0,0 +1,156 @@ +package org.schabi.newpipelegacy.database.playlist.model; + +import android.text.TextUtils; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import org.schabi.newpipelegacy.database.playlist.PlaylistLocalItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipelegacy.util.Constants; + +import static org.schabi.newpipelegacy.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; + +@Entity(tableName = REMOTE_PLAYLIST_TABLE, + indices = { + @Index(value = {REMOTE_PLAYLIST_NAME}), + @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) + }) +public class PlaylistRemoteEntity implements PlaylistLocalItem { + public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists"; + public static final String REMOTE_PLAYLIST_ID = "uid"; + public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id"; + public static final String REMOTE_PLAYLIST_NAME = "name"; + public static final String REMOTE_PLAYLIST_URL = "url"; + public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; + public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"; + public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"; + + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = REMOTE_PLAYLIST_ID) + private long uid = 0; + + @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) + private int serviceId = Constants.NO_SERVICE_ID; + + @ColumnInfo(name = REMOTE_PLAYLIST_NAME) + private String name; + + @ColumnInfo(name = REMOTE_PLAYLIST_URL) + private String url; + + @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) + private String thumbnailUrl; + + @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) + private String uploader; + + @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) + private Long streamCount; + + public PlaylistRemoteEntity(final int serviceId, final String name, final String url, + final String thumbnailUrl, final String uploader, + final Long streamCount) { + this.serviceId = serviceId; + this.name = name; + this.url = url; + this.thumbnailUrl = thumbnailUrl; + this.uploader = uploader; + this.streamCount = streamCount; + } + + @Ignore + public PlaylistRemoteEntity(final PlaylistInfo info) { + this(info.getServiceId(), info.getName(), info.getUrl(), + info.getThumbnailUrl() == null + ? info.getUploaderAvatarUrl() : info.getThumbnailUrl(), + info.getUploaderName(), info.getStreamCount()); + } + + @Ignore + public boolean isIdenticalTo(final PlaylistInfo info) { + /* + * Returns boolean comparing the online playlist and the local copy. + * (False if info changed such as playlist name or track count) + */ + return getServiceId() == info.getServiceId() + && getStreamCount() == info.getStreamCount() + && TextUtils.equals(getName(), info.getName()) + && TextUtils.equals(getUrl(), info.getUrl()) + && TextUtils.equals(getThumbnailUrl(), info.getThumbnailUrl()) + && TextUtils.equals(getUploader(), info.getUploaderName()); + } + + public long getUid() { + return uid; + } + + public void setUid(final long uid) { + this.uid = uid; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(final int serviceId) { + this.serviceId = serviceId; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getThumbnailUrl() { + return thumbnailUrl; + } + + public void setThumbnailUrl(final String thumbnailUrl) { + this.thumbnailUrl = thumbnailUrl; + } + + public String getUrl() { + return url; + } + + public void setUrl(final String url) { + this.url = url; + } + + public String getUploader() { + return uploader; + } + + public void setUploader(final String uploader) { + this.uploader = uploader; + } + + public Long getStreamCount() { + return streamCount; + } + + public void setStreamCount(final Long streamCount) { + this.streamCount = streamCount; + } + + @Override + public LocalItemType getLocalItemType() { + return PLAYLIST_REMOTE_ITEM; + } + + @Override + public String getOrderingName() { + return name; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistStreamEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistStreamEntity.java new file mode 100644 index 000000000..eb2a1aabd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/playlist/model/PlaylistStreamEntity.java @@ -0,0 +1,76 @@ +package org.schabi.newpipelegacy.database.playlist.model; + +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; +import androidx.room.Index; + +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; + +import static androidx.room.ForeignKey.CASCADE; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; + +@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE, + primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX}, + indices = { + @Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true), + @Index(value = {JOIN_STREAM_ID}) + }, + foreignKeys = { + @ForeignKey(entity = PlaylistEntity.class, + parentColumns = PlaylistEntity.PLAYLIST_ID, + childColumns = JOIN_PLAYLIST_ID, + onDelete = CASCADE, onUpdate = CASCADE, deferred = true), + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE, deferred = true) + }) +public class PlaylistStreamEntity { + public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"; + public static final String JOIN_PLAYLIST_ID = "playlist_id"; + public static final String JOIN_STREAM_ID = "stream_id"; + public static final String JOIN_INDEX = "join_index"; + + @ColumnInfo(name = JOIN_PLAYLIST_ID) + private long playlistUid; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @ColumnInfo(name = JOIN_INDEX) + private int index; + + public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) { + this.playlistUid = playlistUid; + this.streamUid = streamUid; + this.index = index; + } + + public long getPlaylistUid() { + return playlistUid; + } + + public void setPlaylistUid(final long playlistUid) { + this.playlistUid = playlistUid; + } + + public long getStreamUid() { + return streamUid; + } + + public void setStreamUid(final long streamUid) { + this.streamUid = streamUid; + } + + public int getIndex() { + return index; + } + + public void setIndex(final int index) { + this.index = index; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipelegacy/database/stream/StreamStatisticsEntry.kt new file mode 100644 index 000000000..4c5427b68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/stream/StreamStatisticsEntry.kt @@ -0,0 +1,42 @@ +package org.schabi.newpipelegacy.database.stream + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import java.util.Date +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipelegacy.database.LocalItem +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity +import org.schabi.newpipelegacy.database.stream.model.StreamEntity + +class StreamStatisticsEntry( + @Embedded + val streamEntity: StreamEntity, + + @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) + val streamId: Long, + + @ColumnInfo(name = STREAM_LATEST_DATE) + val latestAccessDate: Date, + + @ColumnInfo(name = STREAM_WATCH_COUNT) + val watchCount: Long +) : LocalItem { + + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType) + item.duration = streamEntity.duration + item.uploaderName = streamEntity.uploader + item.thumbnailUrl = streamEntity.thumbnailUrl + + return item + } + + override fun getLocalItemType(): LocalItem.LocalItemType { + return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM + } + + companion object { + const val STREAM_LATEST_DATE = "latestAccess" + const val STREAM_WATCH_COUNT = "watchCount" + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamDAO.kt new file mode 100644 index 000000000..9224cd548 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamDAO.kt @@ -0,0 +1,140 @@ +package org.schabi.newpipelegacy.database.stream.dao + +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.Flowable +import java.util.Date +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipelegacy.database.BasicDAO +import org.schabi.newpipelegacy.database.stream.model.StreamEntity +import org.schabi.newpipelegacy.database.stream.model.StreamEntity.Companion.STREAM_ID + +@Dao +abstract class StreamDAO : BasicDAO { + @Query("SELECT * FROM streams") + abstract override fun getAll(): Flowable> + + @Query("DELETE FROM streams") + abstract override fun deleteAll(): Int + + @Query("SELECT * FROM streams WHERE service_id = :serviceId") + abstract override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId") + abstract fun getStream(serviceId: Long, url: String): Flowable> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertInternal(stream: StreamEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertAllInternal(streams: List): List + + @Query(""" + SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration + FROM streams WHERE url = :url AND service_id = :serviceId + """) + internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed? + + @Transaction + open fun upsert(newerStream: StreamEntity): Long { + val uid = silentInsertInternal(newerStream) + + if (uid != -1L) { + newerStream.uid = uid + return uid + } + + compareAndUpdateStream(newerStream) + + update(newerStream) + return newerStream.uid + } + + @Transaction + open fun upsertAll(streams: List): List { + val insertUidList = silentInsertAllInternal(streams) + + val streamIds = ArrayList(streams.size) + for ((index, uid) in insertUidList.withIndex()) { + val newerStream = streams[index] + if (uid != -1L) { + streamIds.add(uid) + newerStream.uid = uid + continue + } + + compareAndUpdateStream(newerStream) + streamIds.add(newerStream.uid) + } + + update(streams) + return streamIds + } + + private fun compareAndUpdateStream(newerStream: StreamEntity) { + val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url) + ?: throw IllegalStateException("Stream cannot be null just after insertion.") + newerStream.uid = existentMinimalStream.uid + + val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM + if (!isNewerStreamLive) { + + // Use the existent upload date if the newer stream does not have a better precision + // (i.e. is an approximation). This is done to prevent unnecessary changes. + val hasBetterPrecision = + newerStream.uploadDate != null && newerStream.isUploadDateApproximation != true + if (existentMinimalStream.uploadDate != null && !hasBetterPrecision) { + newerStream.uploadDate = existentMinimalStream.uploadDate + newerStream.textualUploadDate = existentMinimalStream.textualUploadDate + newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation + } + + if (existentMinimalStream.duration > 0 && newerStream.duration < 0) { + newerStream.duration = existentMinimalStream.duration + } + } + } + + @Query(""" + DELETE FROM streams WHERE + + NOT EXISTS (SELECT 1 FROM stream_history sh + WHERE sh.stream_id = streams.uid) + + AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps + WHERE ps.stream_id = streams.uid) + + AND NOT EXISTS (SELECT 1 FROM feed f + WHERE f.stream_id = streams.uid) + """) + abstract fun deleteOrphans(): Int + + /** + * Minimal entry class used when comparing/updating an existent stream. + */ + internal data class StreamCompareFeed( + @ColumnInfo(name = STREAM_ID) + var uid: Long = 0, + + @ColumnInfo(name = StreamEntity.STREAM_TYPE) + var streamType: StreamType, + + @ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE) + var textualUploadDate: String? = null, + + @ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE) + var uploadDate: Date? = null, + + @ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION) + var isUploadDateApproximation: Boolean? = null, + + @ColumnInfo(name = StreamEntity.STREAM_DURATION) + var duration: Long + ) +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamStateDAO.java new file mode 100644 index 000000000..67da6605a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/stream/dao/StreamStateDAO.java @@ -0,0 +1,48 @@ +package org.schabi.newpipelegacy.database.stream.dao; + +import androidx.room.Dao; +import androidx.room.Insert; +import androidx.room.OnConflictStrategy; +import androidx.room.Query; +import androidx.room.Transaction; + +import org.schabi.newpipelegacy.database.BasicDAO; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; + +import java.util.List; + +import io.reactivex.Flowable; + +import static org.schabi.newpipelegacy.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; + +@Dao +public abstract class StreamStateDAO implements BasicDAO { + @Override + @Query("SELECT * FROM " + STREAM_STATE_TABLE) + public abstract Flowable> getAll(); + + @Override + @Query("DELETE FROM " + STREAM_STATE_TABLE) + public abstract int deleteAll(); + + @Override + public Flowable> listByService(final int serviceId) { + throw new UnsupportedOperationException(); + } + + @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract Flowable> getState(long streamId); + + @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") + public abstract int deleteState(long streamId); + + @Insert(onConflict = OnConflictStrategy.IGNORE) + abstract void silentInsertInternal(StreamStateEntity streamState); + + @Transaction + public long upsert(final StreamStateEntity stream) { + silentInsertInternal(stream); + return update(stream); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamEntity.kt b/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamEntity.kt new file mode 100644 index 000000000..62c4f5659 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamEntity.kt @@ -0,0 +1,121 @@ +package org.schabi.newpipelegacy.database.stream.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import java.io.Serializable +import java.util.Calendar +import java.util.Date +import org.schabi.newpipe.extractor.localization.DateWrapper +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipelegacy.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID +import org.schabi.newpipelegacy.database.stream.model.StreamEntity.Companion.STREAM_TABLE +import org.schabi.newpipelegacy.database.stream.model.StreamEntity.Companion.STREAM_URL +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem + +@Entity(tableName = STREAM_TABLE, + indices = [ + Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true) + ] +) +data class StreamEntity( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = STREAM_ID) + var uid: Long = 0, + + @ColumnInfo(name = STREAM_SERVICE_ID) + var serviceId: Int, + + @ColumnInfo(name = STREAM_URL) + var url: String, + + @ColumnInfo(name = STREAM_TITLE) + var title: String, + + @ColumnInfo(name = STREAM_TYPE) + var streamType: StreamType, + + @ColumnInfo(name = STREAM_DURATION) + var duration: Long, + + @ColumnInfo(name = STREAM_UPLOADER) + var uploader: String, + + @ColumnInfo(name = STREAM_THUMBNAIL_URL) + var thumbnailUrl: String? = null, + + @ColumnInfo(name = STREAM_VIEWS) + var viewCount: Long? = null, + + @ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE) + var textualUploadDate: String? = null, + + @ColumnInfo(name = STREAM_UPLOAD_DATE) + var uploadDate: Date? = null, + + @ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION) + var isUploadDateApproximation: Boolean? = null +) : Serializable { + + @Ignore + constructor(item: StreamInfoItem) : this( + serviceId = item.serviceId, url = item.url, title = item.name, + streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, + thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount, + textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.date()?.time, + isUploadDateApproximation = item.uploadDate?.isApproximation + ) + + @Ignore + constructor(info: StreamInfo) : this( + serviceId = info.serviceId, url = info.url, title = info.name, + streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, + thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount, + textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.date()?.time, + isUploadDateApproximation = info.uploadDate?.isApproximation + ) + + @Ignore + constructor(item: PlayQueueItem) : this( + serviceId = item.serviceId, url = item.url, title = item.title, + streamType = item.streamType, duration = item.duration, uploader = item.uploader, + thumbnailUrl = item.thumbnailUrl + ) + + fun toStreamInfoItem(): StreamInfoItem { + val item = StreamInfoItem(serviceId, url, title, streamType) + item.duration = duration + item.uploaderName = uploader + item.thumbnailUrl = thumbnailUrl + + if (viewCount != null) item.viewCount = viewCount as Long + item.textualUploadDate = textualUploadDate + item.uploadDate = uploadDate?.let { + DateWrapper(Calendar.getInstance().apply { time = it }, isUploadDateApproximation + ?: false) + } + + return item + } + + companion object { + const val STREAM_TABLE = "streams" + const val STREAM_ID = "uid" + const val STREAM_SERVICE_ID = "service_id" + const val STREAM_URL = "url" + const val STREAM_TITLE = "title" + const val STREAM_TYPE = "stream_type" + const val STREAM_DURATION = "duration" + const val STREAM_UPLOADER = "uploader" + const val STREAM_THUMBNAIL_URL = "thumbnail_url" + + const val STREAM_VIEWS = "view_count" + const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date" + const val STREAM_UPLOAD_DATE = "upload_date" + const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation" + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamStateEntity.java new file mode 100644 index 000000000..caa187e5a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/stream/model/StreamStateEntity.java @@ -0,0 +1,78 @@ +package org.schabi.newpipelegacy.database.stream.model; + +import androidx.annotation.Nullable; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.ForeignKey; + +import java.util.concurrent.TimeUnit; + +import static androidx.room.ForeignKey.CASCADE; +import static org.schabi.newpipelegacy.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; +import static org.schabi.newpipelegacy.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; + +@Entity(tableName = STREAM_STATE_TABLE, + primaryKeys = {JOIN_STREAM_ID}, + foreignKeys = { + @ForeignKey(entity = StreamEntity.class, + parentColumns = StreamEntity.STREAM_ID, + childColumns = JOIN_STREAM_ID, + onDelete = CASCADE, onUpdate = CASCADE) + }) +public class StreamStateEntity { + public static final String STREAM_STATE_TABLE = "stream_state"; + public static final String JOIN_STREAM_ID = "stream_id"; + public static final String STREAM_PROGRESS_TIME = "progress_time"; + + /** + * Playback state will not be saved, if playback time is less than this threshold. + */ + private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5; + /** + * Playback state will not be saved, if time left is less than this threshold. + */ + private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10; + + @ColumnInfo(name = JOIN_STREAM_ID) + private long streamUid; + + @ColumnInfo(name = STREAM_PROGRESS_TIME) + private long progressTime; + + public StreamStateEntity(final long streamUid, final long progressTime) { + this.streamUid = streamUid; + this.progressTime = progressTime; + } + + public long getStreamUid() { + return streamUid; + } + + public void setStreamUid(final long streamUid) { + this.streamUid = streamUid; + } + + public long getProgressTime() { + return progressTime; + } + + public void setProgressTime(final long progressTime) { + this.progressTime = progressTime; + } + + public boolean isValid(final int durationInSeconds) { + final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime); + return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS + && seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS; + } + + @Override + public boolean equals(@Nullable final Object obj) { + if (obj instanceof StreamStateEntity) { + return ((StreamStateEntity) obj).streamUid == streamUid + && ((StreamStateEntity) obj).progressTime == progressTime; + } else { + return false; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionDAO.kt new file mode 100644 index 000000000..24ed3bd70 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionDAO.kt @@ -0,0 +1,103 @@ +package org.schabi.newpipelegacy.database.subscription + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import io.reactivex.Flowable +import io.reactivex.Maybe +import org.schabi.newpipelegacy.database.BasicDAO + +@Dao +abstract class SubscriptionDAO : BasicDAO { + @Query("SELECT COUNT(*) FROM subscriptions") + abstract fun rowCount(): Flowable + + @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId") + abstract override fun listByService(serviceId: Int): Flowable> + + @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") + abstract override fun getAll(): Flowable> + + @Query(""" + SELECT * FROM subscriptions + + WHERE name LIKE '%' || :filter || '%' + + ORDER BY name COLLATE NOCASE ASC + """) + abstract fun getSubscriptionsFiltered(filter: String): Flowable> + + @Query(""" + SELECT * FROM subscriptions s + + LEFT JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id + + WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) + + ORDER BY name COLLATE NOCASE ASC + """) + abstract fun getSubscriptionsOnlyUngrouped( + currentGroupId: Long + ): Flowable> + + @Query(""" + SELECT * FROM subscriptions s + + LEFT JOIN feed_group_subscription_join fgs + ON s.uid = fgs.subscription_id + + WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) + AND s.name LIKE '%' || :filter || '%' + + ORDER BY name COLLATE NOCASE ASC + """) + abstract fun getSubscriptionsOnlyUngroupedFiltered( + currentGroupId: Long, + filter: String + ): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable> + + @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun getSubscription(serviceId: Int, url: String): Maybe + + @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId") + abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity + + @Query("DELETE FROM subscriptions") + abstract override fun deleteAll(): Int + + @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + abstract fun deleteSubscription(serviceId: Int, url: String): Int + + @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") + internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + internal abstract fun silentInsertAllInternal(entities: List): List + + @Transaction + open fun upsertAll(entities: List): List { + val insertUidList = silentInsertAllInternal(entities) + + insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> + val entity = entities[index] + + if (uidFromInsert != -1L) { + entity.uid = uidFromInsert + } else { + val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url) + ?: throw IllegalStateException("Subscription cannot be null just after insertion.") + entity.uid = subscriptionIdFromDb + + update(entity) + } + } + + return entities + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionEntity.java new file mode 100644 index 000000000..2568f4f8d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/database/subscription/SubscriptionEntity.java @@ -0,0 +1,184 @@ +package org.schabi.newpipelegacy.database.subscription; + +import androidx.annotation.NonNull; +import androidx.room.ColumnInfo; +import androidx.room.Entity; +import androidx.room.Ignore; +import androidx.room.Index; +import androidx.room.PrimaryKey; + +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipelegacy.util.Constants; + +import static org.schabi.newpipelegacy.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; +import static org.schabi.newpipelegacy.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; +import static org.schabi.newpipelegacy.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; + +@Entity(tableName = SUBSCRIPTION_TABLE, + indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) +public class SubscriptionEntity { + public static final String SUBSCRIPTION_UID = "uid"; + public static final String SUBSCRIPTION_TABLE = "subscriptions"; + public static final String SUBSCRIPTION_SERVICE_ID = "service_id"; + public static final String SUBSCRIPTION_URL = "url"; + public static final String SUBSCRIPTION_NAME = "name"; + public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; + public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; + public static final String SUBSCRIPTION_DESCRIPTION = "description"; + + @PrimaryKey(autoGenerate = true) + private long uid = 0; + + @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) + private int serviceId = Constants.NO_SERVICE_ID; + + @ColumnInfo(name = SUBSCRIPTION_URL) + private String url; + + @ColumnInfo(name = SUBSCRIPTION_NAME) + private String name; + + @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) + private String avatarUrl; + + @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) + private Long subscriberCount; + + @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) + private String description; + + @Ignore + public static SubscriptionEntity from(@NonNull final ChannelInfo info) { + SubscriptionEntity result = new SubscriptionEntity(); + result.setServiceId(info.getServiceId()); + result.setUrl(info.getUrl()); + result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), + info.getSubscriberCount()); + return result; + } + + public long getUid() { + return uid; + } + + public void setUid(final long uid) { + this.uid = uid; + } + + public int getServiceId() { + return serviceId; + } + + public void setServiceId(final int serviceId) { + this.serviceId = serviceId; + } + + public String getUrl() { + return url; + } + + public void setUrl(final String url) { + this.url = url; + } + + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(final String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Long getSubscriberCount() { + return subscriberCount; + } + + public void setSubscriberCount(final Long subscriberCount) { + this.subscriberCount = subscriberCount; + } + + public String getDescription() { + return description; + } + + public void setDescription(final String description) { + this.description = description; + } + + @Ignore + public void setData(final String n, final String au, final String d, final Long sc) { + this.setName(n); + this.setAvatarUrl(au); + this.setDescription(d); + this.setSubscriberCount(sc); + } + + @Ignore + public ChannelInfoItem toChannelInfoItem() { + ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); + item.setThumbnailUrl(getAvatarUrl()); + item.setSubscriberCount(getSubscriberCount()); + item.setDescription(getDescription()); + return item; + } + + + // TODO: Remove these generated methods by migrating this class to a data class from Kotlin. + @Override + @SuppressWarnings("EqualsReplaceableByObjectsCall") + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + final SubscriptionEntity that = (SubscriptionEntity) o; + + if (uid != that.uid) { + return false; + } + if (serviceId != that.serviceId) { + return false; + } + if (!url.equals(that.url)) { + return false; + } + if (name != null ? !name.equals(that.name) : that.name != null) { + return false; + } + if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) { + return false; + } + if (subscriberCount != null + ? !subscriberCount.equals(that.subscriberCount) + : that.subscriberCount != null) { + return false; + } + return description != null + ? description.equals(that.description) + : that.description == null; + } + + @Override + public int hashCode() { + int result = (int) (uid ^ (uid >>> 32)); + result = 31 * result + serviceId; + result = 31 * result + url.hashCode(); + result = 31 * result + (name != null ? name.hashCode() : 0); + result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0); + result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0); + result = 31 * result + (description != null ? description.hashCode() : 0); + return result; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipelegacy/download/DownloadActivity.java new file mode 100644 index 000000000..7e4ef1193 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/download/DownloadActivity.java @@ -0,0 +1,94 @@ +package org.schabi.newpipelegacy.download; + +import android.app.FragmentTransaction; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.ViewTreeObserver; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.FocusOverlayView; + +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.ui.fragment.MissionsFragment; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class DownloadActivity extends AppCompatActivity { + + private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag"; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + // Service + Intent i = new Intent(); + i.setClass(this, DownloadManagerService.class); + startService(i); + + assureCorrectAppLanguage(this); + ThemeHelper.setTheme(this); + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_downloader); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.downloads_title); + actionBar.setDisplayShowTitleEnabled(true); + } + + getWindow().getDecorView().getViewTreeObserver() + .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + updateFragments(); + getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this); + } + }); + + if (AndroidTvUtils.isTv(this)) { + FocusOverlayView.setupFocusObserver(this); + } + } + + private void updateFragments() { + MissionsFragment fragment = new MissionsFragment(); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .commit(); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + super.onCreateOptionsMenu(menu); + MenuInflater inflater = getMenuInflater(); + + inflater.inflate(R.menu.download_menu, menu); + + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipelegacy/download/DownloadDialog.java new file mode 100644 index 000000000..e91141dfc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/download/DownloadDialog.java @@ -0,0 +1,921 @@ +package org.schabi.newpipelegacy.download; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.EditText; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.IdRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.view.menu.ActionMenuItemView; +import androidx.appcompat.widget.Toolbar; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.DialogFragment; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.RouterActivity; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.settings.NewPipeSettings; +import org.schabi.newpipelegacy.util.FilePickerActivityHelper; +import org.schabi.newpipelegacy.util.FilenameUtils; +import org.schabi.newpipelegacy.util.ListHelper; +import org.schabi.newpipelegacy.util.PermissionHelper; +import org.schabi.newpipelegacy.util.SecondaryStreamHelper; +import org.schabi.newpipelegacy.util.StreamItemAdapter; +import org.schabi.newpipelegacy.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +import icepick.Icepick; +import icepick.State; +import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.get.MissionRecoveryInfo; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManager; +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; +import us.shandian.giga.service.MissionState; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class DownloadDialog extends DialogFragment + implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { + private static final String TAG = "DialogFragment"; + private static final boolean DEBUG = MainActivity.DEBUG; + private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230; + + @State + StreamInfo currentInfo; + @State + StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + @State + StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + @State + StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + @State + int selectedVideoIndex = 0; + @State + int selectedAudioIndex = 0; + @State + int selectedSubtitleIndex = 0; + + private StoredDirectoryHelper mainStorageAudio = null; + private StoredDirectoryHelper mainStorageVideo = null; + private DownloadManager downloadManager = null; + private ActionMenuItemView okButton = null; + private Context context; + private boolean askForSavePath; + + private StreamItemAdapter audioStreamsAdapter; + private StreamItemAdapter videoStreamsAdapter; + private StreamItemAdapter subtitleStreamsAdapter; + + private final CompositeDisposable disposables = new CompositeDisposable(); + + private EditText nameEditText; + private Spinner streamsSpinner; + private RadioGroup radioStreamsGroup; + private TextView threadsCountTextView; + private SeekBar threadsSeekBar; + + private SharedPreferences prefs; + + public static DownloadDialog newInstance(final StreamInfo info) { + DownloadDialog dialog = new DownloadDialog(); + dialog.setInfo(info); + return dialog; + } + + public static DownloadDialog newInstance(final Context context, final StreamInfo info) { + final ArrayList streamsList = new ArrayList<>(ListHelper + .getSortedStreamVideosList(context, info.getVideoStreams(), + info.getVideoOnlyStreams(), false)); + final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); + + final DownloadDialog instance = newInstance(info); + instance.setVideoStreams(streamsList); + instance.setSelectedVideoStream(selectedStreamIndex); + instance.setAudioStreams(info.getAudioStreams()); + instance.setSubtitleStreams(info.getSubtitles()); + + return instance; + } + + private void setInfo(final StreamInfo info) { + this.currentInfo = info; + } + + public void setAudioStreams(final List audioStreams) { + setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); + } + + public void setAudioStreams(final StreamSizeWrapper was) { + this.wrappedAudioStreams = was; + } + + public void setVideoStreams(final List videoStreams) { + setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + public void setVideoStreams(final StreamSizeWrapper wvs) { + this.wrappedVideoStreams = wvs; + } + + public void setSubtitleStreams(final List subtitleStreams) { + setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); + } + + public void setSubtitleStreams( + final StreamSizeWrapper wss) { + this.wrappedSubtitleStreams = wss; + } + + public void setSelectedVideoStream(final int svi) { + this.selectedVideoIndex = svi; + } + + public void setSelectedAudioStream(final int sai) { + this.selectedAudioIndex = sai; + } + + public void setSelectedSubtitleStream(final int ssi) { + this.selectedSubtitleIndex = ssi; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (DEBUG) { + Log.d(TAG, "onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + + if (!PermissionHelper.checkStoragePermissions(getActivity(), + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + getDialog().dismiss(); + return; + } + + context = getContext(); + + setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); + Icepick.restoreInstanceState(this, savedInstanceState); + + 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<>(context, wrappedVideoStreams, + secondaryStreams); + this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams); + + Intent intent = new Intent(context, DownloadManagerService.class); + context.startService(intent); + + context.bindService(intent, new ServiceConnection() { + @Override + public void onServiceConnected(final ComponentName cname, final IBinder service) { + DownloadManagerBinder mgr = (DownloadManagerBinder) service; + + mainStorageAudio = mgr.getMainStorageAudio(); + mainStorageVideo = mgr.getMainStorageVideo(); + downloadManager = mgr.getDownloadManager(); + askForSavePath = mgr.askForSavePath(); + + okButton.setEnabled(true); + + context.unbindService(this); + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + // nothing to do + } + }, Context.BIND_AUTO_CREATE); + } + + /*////////////////////////////////////////////////////////////////////////// + // Inits + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + if (DEBUG) { + Log.d(TAG, "onCreateView() called with: " + + "inflater = [" + inflater + "], container = [" + container + "], " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + return inflater.inflate(R.layout.download_dialog, container); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + nameEditText = view.findViewById(R.id.file_name); + 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); + + threadsCountTextView = view.findViewById(R.id.threads_count); + threadsSeekBar = view.findViewById(R.id.threads); + + radioStreamsGroup = view.findViewById(R.id.video_audio_group); + radioStreamsGroup.setOnCheckedChangeListener(this); + + initToolbar(view.findViewById(R.id.toolbar)); + setupDownloadOptions(); + + 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(final SeekBar seekbar, final int progress, + final boolean fromUser) { + final int newProgress = progress + 1; + prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) + .apply(); + threadsCountTextView.setText(String.valueOf(newProgress)); + } + + @Override + public void onStartTrackingTouch(final SeekBar p1) { } + + @Override + public void onStopTrackingTouch(final SeekBar p1) { } + }); + + fetchStreamsSize(); + } + + private void fetchStreamsSize() { + disposables.clear(); + + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) + .subscribe(result -> { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.video_button) { + setupVideoSpinner(); + } + })); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams) + .subscribe(result -> { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.audio_button) { + setupAudioSpinner(); + } + })); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) + .subscribe(result -> { + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { + setupSubtitleSpinner(); + } + })); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposables.clear(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Radio group Video&Audio options - Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onSaveInstanceState(@NonNull final Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } + + /*////////////////////////////////////////////////////////////////////////// + // Streams Spinner Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode == REQUEST_DOWNLOAD_SAVE_AS && resultCode == Activity.RESULT_OK) { + if (data.getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + + if (FilePickerActivityHelper.isOwnFileUri(context, data.getData())) { + File file = Utils.getFileForUri(data.getData()); + checkSelectedDownload(null, Uri.fromFile(file), file.getName(), + StoredFileHelper.DEFAULT_MIME); + return; + } + + DocumentFile docFile = DocumentFile.fromSingleUri(context, data.getData()); + if (docFile == null) { + showFailedDialog(R.string.general_error); + return; + } + + // check if the selected file was previously used + checkSelectedDownload(null, data.getData(), docFile.getName(), + docFile.getType()); + } + } + + private void initToolbar(final Toolbar toolbar) { + if (DEBUG) { + Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); + } + + toolbar.setTitle(R.string.download_dialog_title); + toolbar.setNavigationIcon( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_arrow_back)); + toolbar.inflateMenu(R.menu.dialog_url); + toolbar.setNavigationOnClickListener(v -> requireDialog().dismiss()); + toolbar.setNavigationContentDescription(R.string.cancel); + + okButton = toolbar.findViewById(R.id.okay); + okButton.setEnabled(false); // disable until the download service connection is done + + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.okay) { + prepareSelectedDownload(); + if (getActivity() instanceof RouterActivity) { + getActivity().finish(); + } + return true; + } + return false; + }); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void setupAudioSpinner() { + if (getContext() == null) { + return; + } + + streamsSpinner.setAdapter(audioStreamsAdapter); + streamsSpinner.setSelection(selectedAudioIndex); + setRadioButtonsState(true); + } + + private void setupVideoSpinner() { + if (getContext() == null) { + return; + } + + streamsSpinner.setAdapter(videoStreamsAdapter); + streamsSpinner.setSelection(selectedVideoIndex); + setRadioButtonsState(true); + } + + private void setupSubtitleSpinner() { + if (getContext() == null) { + return; + } + + streamsSpinner.setAdapter(subtitleStreamsAdapter); + streamsSpinner.setSelection(selectedSubtitleIndex); + setRadioButtonsState(true); + } + + @Override + public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) { + if (DEBUG) { + Log.d(TAG, "onCheckedChanged() called with: " + + "group = [" + group + "], checkedId = [" + checkedId + "]"); + } + boolean flag = true; + + switch (checkedId) { + case R.id.audio_button: + setupAudioSpinner(); + break; + case R.id.video_button: + setupVideoSpinner(); + break; + case R.id.subtitle_button: + setupSubtitleSpinner(); + flag = false; + break; + } + + threadsSeekBar.setEnabled(flag); + } + + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + if (DEBUG) { + Log.d(TAG, "onItemSelected() called with: " + + "parent = [" + parent + "], view = [" + view + "], " + + "position = [" + position + "], id = [" + id + "]"); + } + switch (radioStreamsGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + selectedAudioIndex = position; + break; + case R.id.video_button: + selectedVideoIndex = position; + break; + case R.id.subtitle_button: + selectedSubtitleIndex = position; + break; + } + } + + @Override + public void onNothingSelected(final AdapterView parent) { + } + + protected void setupDownloadOptions() { + setRadioButtonsState(false); + + final RadioButton audioButton = radioStreamsGroup.findViewById(R.id.audio_button); + final RadioButton videoButton = radioStreamsGroup.findViewById(R.id.video_button); + final RadioButton subtitleButton = radioStreamsGroup.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); + setupVideoSpinner(); + } 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(); + } + } + + private void setRadioButtonsState(final boolean enabled) { + radioStreamsGroup.findViewById(R.id.audio_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.video_button).setEnabled(enabled); + radioStreamsGroup.findViewById(R.id.subtitle_button).setEnabled(enabled); + } + + private int getSubtitleIndexBy(final List streams) { + final Localization preferredLocalization = NewPipe.getPreferredLocalization(); + + int candidate = 0; + for (int i = 0; i < streams.size(); i++) { + final Locale streamLocale = streams.get(i).getLocale(); + + final boolean languageEquals = streamLocale.getLanguage() != null + && preferredLocalization.getLanguageCode() != null + && streamLocale.getLanguage() + .equals(new Locale(preferredLocalization.getLanguageCode()).getLanguage()); + final boolean countryEquals = streamLocale.getCountry() != null + && streamLocale.getCountry().equals(preferredLocalization.getCountryCode()); + + if (languageEquals) { + if (countryEquals) { + return i; + } + + candidate = i; + } + } + + return candidate; + } + + private String getNameEditText() { + String str = nameEditText.getText().toString().trim(); + + return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); + } + + private void showFailedDialog(@StringRes final int msg) { + assureCorrectAppLanguage(getContext()); + new AlertDialog.Builder(context) + .setTitle(R.string.general_error) + .setMessage(msg) + .setNegativeButton(getString(R.string.finish), null) + .create() + .show(); + } + + private void showErrorActivity(final Exception e) { + ErrorActivity.reportError( + context, + Collections.singletonList(e), + null, + null, + ErrorActivity.ErrorInfo + .make(UserAction.SOMETHING_ELSE, "-", "-", R.string.general_error) + ); + } + + private void prepareSelectedDownload() { + StoredDirectoryHelper mainStorage; + MediaFormat format; + String mime; + + // first, build the filename and get the output folder (if possible) + // later, run a very very very large file checking logic + + String filename = getNameEditText().concat("."); + + switch (radioStreamsGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + mainStorage = mainStorageAudio; + format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); + switch (format) { + case WEBMA_OPUS: + mime = "audio/ogg"; + filename += "opus"; + break; + default: + mime = format.mimeType; + filename += format.suffix; + break; + } + break; + case R.id.video_button: + mainStorage = mainStorageVideo; + format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); + mime = format.mimeType; + filename += format.suffix; + break; + case R.id.subtitle_button: + mainStorage = mainStorageVideo; // subtitle & video files go together + format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); + mime = format.mimeType; + filename += format == MediaFormat.TTML ? MediaFormat.SRT.suffix : format.suffix; + break; + default: + throw new RuntimeException("No stream selected"); + } + + if (mainStorage == null || askForSavePath) { + // This part is called if with SAF preferred: + // * older android version running + // * save path not defined (via download settings) + // * the user checked the "ask where to download" option + + if (!askForSavePath) { + Toast.makeText(context, getString(R.string.no_available_dir), + Toast.LENGTH_LONG).show(); + } + + if (NewPipeSettings.useStorageAccessFramework(context)) { + StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS, + filename, mime); + } else { + File initialSavePath; + if (radioStreamsGroup.getCheckedRadioButtonId() == R.id.audio_button) { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); + } else { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + } + + initialSavePath = new File(initialSavePath, filename); + startActivityForResult(FilePickerActivityHelper.chooseFileToSave(context, + initialSavePath.getAbsolutePath()), REQUEST_DOWNLOAD_SAVE_AS); + } + + return; + } + + // check for existing file with the same name + checkSelectedDownload(mainStorage, mainStorage.findFile(filename), filename, mime); + } + + private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, + final Uri targetFile, final String filename, + final String mime) { + StoredFileHelper storage; + + try { + if (mainStorage == null) { + // using SAF on older android version + storage = new StoredFileHelper(context, null, targetFile, ""); + } else if (targetFile == null) { + // the file does not exist, but it is probably used in a pending download + storage = new StoredFileHelper(mainStorage.getUri(), filename, mime, + mainStorage.getTag()); + } else { + // the target filename is already use, attempt to use it + storage = new StoredFileHelper(context, mainStorage.getUri(), targetFile, + mainStorage.getTag()); + } + } catch (Exception e) { + showErrorActivity(e); + return; + } + + // check if is our file + MissionState state = downloadManager.checkForExistingMission(storage); + @StringRes int msgBtn; + @StringRes int msgBody; + + switch (state) { + case Finished: + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_finished_warning; + break; + case Pending: + msgBtn = R.string.overwrite; + msgBody = R.string.download_already_pending; + break; + case PendingRunning: + msgBtn = R.string.generate_unique_name; + msgBody = R.string.download_already_running; + break; + case None: + if (mainStorage == null) { + // This part is called if: + // * using SAF on older android version + // * save path not defined + // * if the file exists overwrite it, is not necessary ask + if (!storage.existsAsFile() && !storage.create()) { + showFailedDialog(R.string.error_file_creation); + return; + } + continueSelectedDownload(storage); + return; + } else if (targetFile == null) { + // This part is called if: + // * the filename is not used in a pending/finished download + // * the file does not exists, create + + if (!mainStorage.mkdirs()) { + showFailedDialog(R.string.error_path_creation); + return; + } + + storage = mainStorage.createFile(filename, mime); + if (storage == null || !storage.canWrite()) { + showFailedDialog(R.string.error_file_creation); + return; + } + + continueSelectedDownload(storage); + return; + } + msgBtn = R.string.overwrite; + msgBody = R.string.overwrite_unrelated_warning; + break; + default: + return; + } + + + AlertDialog.Builder askDialog = new AlertDialog.Builder(context) + .setTitle(R.string.download_dialog_title) + .setMessage(msgBody) + .setNegativeButton(android.R.string.cancel, null); + final StoredFileHelper finalStorage = storage; + + + if (mainStorage == null) { + // This part is called if: + // * using SAF on older android version + // * save path not defined + switch (state) { + case Pending: + case Finished: + askDialog.setPositiveButton(msgBtn, (dialog, which) -> { + dialog.dismiss(); + downloadManager.forgetMission(finalStorage); + continueSelectedDownload(finalStorage); + }); + break; + } + + askDialog.create().show(); + return; + } + + askDialog.setPositiveButton(msgBtn, (dialog, which) -> { + dialog.dismiss(); + + StoredFileHelper storageNew; + switch (state) { + case Finished: + case Pending: + downloadManager.forgetMission(finalStorage); + case None: + if (targetFile == null) { + storageNew = mainStorage.createFile(filename, mime); + } else { + try { + // try take (or steal) the file + storageNew = new StoredFileHelper(context, mainStorage.getUri(), + targetFile, mainStorage.getTag()); + } catch (IOException e) { + Log.e(TAG, "Failed to take (or steal) the file in " + + targetFile.toString()); + storageNew = null; + } + } + + if (storageNew != null && storageNew.canWrite()) { + continueSelectedDownload(storageNew); + } else { + showFailedDialog(R.string.error_file_creation); + } + break; + case PendingRunning: + storageNew = mainStorage.createUniqueFile(filename, mime); + if (storageNew == null) { + showFailedDialog(R.string.error_file_creation); + } else { + continueSelectedDownload(storageNew); + } + break; + } + }); + + askDialog.create().show(); + } + + private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { + if (!storage.canWrite()) { + showFailedDialog(R.string.permission_denied); + return; + } + + // check if the selected file has to be overwritten, by simply checking its length + try { + if (storage.length() > 0) { + storage.truncate(); + } + } catch (IOException e) { + Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e); + showFailedDialog(R.string.overwrite_failed); + return; + } + + Stream selectedStream; + Stream secondaryStream = null; + char kind; + int threads = threadsSeekBar.getProgress() + 1; + String[] urls; + MissionRecoveryInfo[] recoveryInfo; + String psName = null; + String[] psArgs = null; + long nearLength = 0; + + // more download logic: select muxer, subtitle converter, etc. + switch (radioStreamsGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + kind = 'a'; + selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); + + if (selectedStream.getFormat() == MediaFormat.M4A) { + psName = Postprocessing.ALGORITHM_M4A_NO_DASH; + } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { + psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; + } + break; + case R.id.video_button: + kind = 'v'; + selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); + + SecondaryStreamHelper secondary = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); + + if (secondary != null) { + secondaryStream = secondary.getStream(); + + if (selectedStream.getFormat() == MediaFormat.MPEG_4) { + psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; + } else { + psName = Postprocessing.ALGORITHM_WEBM_MUXER; + } + + psArgs = null; + long videoSize = wrappedVideoStreams + .getSizeInBytes((VideoStream) selectedStream); + + // set nearLength, only, if both sizes are fetched or known. This probably + // does not work on slow networks but is later updated in the downloader + if (secondary.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondary.getSizeInBytes() + videoSize; + } + } + break; + case R.id.subtitle_button: + threads = 1; // use unique thread for subtitles due small file size + kind = 's'; + selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); + + if (selectedStream.getFormat() == MediaFormat.TTML) { + psName = Postprocessing.ALGORITHM_TTML_CONVERTER; + psArgs = new String[]{ + selectedStream.getFormat().getSuffix(), + "false" // ignore empty frames + }; + } + break; + default: + return; + } + + if (secondaryStream == null) { + urls = new String[]{ + selectedStream.getUrl() + }; + recoveryInfo = new MissionRecoveryInfo[]{ + new MissionRecoveryInfo(selectedStream) + }; + } else { + urls = new String[]{ + selectedStream.getUrl(), secondaryStream.getUrl() + }; + recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream), + new MissionRecoveryInfo(secondaryStream)}; + } + + DownloadManagerService.startMission(context, urls, storage, kind, threads, + currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo); + + dismiss(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/BackPressable.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/BackPressable.java new file mode 100644 index 000000000..2c8f04959 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/BackPressable.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.fragments; + +/** + * Indicates that the current fragment can handle back presses. + */ +public interface BackPressable { + /** + * A back press was delegated to this fragment. + * + * @return if the back press was handled + */ + boolean onBackPressed(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/BaseStateFragment.java new file mode 100644 index 000000000..76a4a479e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/BaseStateFragment.java @@ -0,0 +1,296 @@ +package org.schabi.newpipelegacy.fragments; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.jakewharton.rxbinding2.view.RxView; + +import org.schabi.newpipelegacy.BaseFragment; +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.ReCaptchaActivity; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.ExceptionUtils; +import org.schabi.newpipelegacy.util.InfoCache; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import icepick.State; +import io.reactivex.android.schedulers.AndroidSchedulers; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public abstract class BaseStateFragment extends BaseFragment implements ViewContract { + @State + protected AtomicBoolean wasLoading = new AtomicBoolean(); + protected AtomicBoolean isLoading = new AtomicBoolean(); + + @Nullable + private View emptyStateView; + @Nullable + private ProgressBar loadingProgressBar; + + protected View errorPanelRoot; + private Button errorButtonRetry; + private TextView errorTextView; + + @Override + public void onViewCreated(final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + doInitialLoadLogic(); + } + + @Override + public void onPause() { + super.onPause(); + wasLoading.set(isLoading.get()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + emptyStateView = rootView.findViewById(R.id.empty_state_view); + loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); + + errorPanelRoot = rootView.findViewById(R.id.error_panel); + errorButtonRetry = rootView.findViewById(R.id.error_button_retry); + errorTextView = rootView.findViewById(R.id.error_message_view); + } + + @Override + protected void initListeners() { + super.initListeners(); + RxView.clicks(errorButtonRetry) + .debounce(300, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(o -> onRetryButtonClicked()); + } + + protected void onRetryButtonClicked() { + reloadContent(); + } + + public void reloadContent() { + startLoading(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Load + //////////////////////////////////////////////////////////////////////////*/ + + protected void doInitialLoadLogic() { + startLoading(true); + } + + protected void startLoading(final boolean forceLoad) { + if (DEBUG) { + Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]"); + } + showLoading(); + isLoading.set(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + if (emptyStateView != null) { + animateView(emptyStateView, false, 150); + } + if (loadingProgressBar != null) { + animateView(loadingProgressBar, true, 400); + } + animateView(errorPanelRoot, false, 150); + } + + @Override + public void hideLoading() { + if (emptyStateView != null) { + animateView(emptyStateView, false, 150); + } + if (loadingProgressBar != null) { + animateView(loadingProgressBar, false, 0); + } + animateView(errorPanelRoot, false, 150); + } + + @Override + public void showEmptyState() { + isLoading.set(false); + if (emptyStateView != null) { + animateView(emptyStateView, true, 200); + } + if (loadingProgressBar != null) { + animateView(loadingProgressBar, false, 0); + } + animateView(errorPanelRoot, false, 150); + } + + @Override + public void showError(final String message, final boolean showRetryButton) { + if (DEBUG) { + Log.d(TAG, "showError() called with: " + + "message = [" + message + "], showRetryButton = [" + showRetryButton + "]"); + } + isLoading.set(false); + InfoCache.getInstance().clearCache(); + hideLoading(); + + errorTextView.setText(message); + if (showRetryButton) { + animateView(errorButtonRetry, true, 600); + } else { + animateView(errorButtonRetry, false, 0); + } + animateView(errorPanelRoot, true, 300); + } + + @Override + public void handleResult(final I result) { + if (DEBUG) { + Log.d(TAG, "handleResult() called with: result = [" + result + "]"); + } + hideLoading(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Default implementation handles some general exceptions. + * + * @param exception The exception that should be handled + * @return If the exception was handled + */ + protected boolean onError(final Throwable exception) { + if (DEBUG) { + Log.d(TAG, "onError() called with: exception = [" + exception + "]"); + } + isLoading.set(false); + + if (isDetached() || isRemoving()) { + if (DEBUG) { + Log.w(TAG, "onError() is detached or removing = [" + exception + "]"); + } + return true; + } + + if (ExceptionUtils.isInterruptedCaused(exception)) { + if (DEBUG) { + Log.w(TAG, "onError() isInterruptedCaused! = [" + exception + "]"); + } + return true; + } + + if (exception instanceof ReCaptchaException) { + onReCaptchaException((ReCaptchaException) exception); + return true; + } else if (exception instanceof ContentNotAvailableException) { + showError(getString(R.string.content_not_available), false); + return true; + } else if (ExceptionUtils.isNetworkRelated(exception)) { + showError(getString(R.string.network_error), true); + return true; + } else if (exception instanceof ContentNotSupportedException) { + showError(getString(R.string.content_not_supported), false); + return true; + } + + return false; + } + + public void onReCaptchaException(final ReCaptchaException exception) { + if (DEBUG) { + Log.d(TAG, "onReCaptchaException() called"); + } + Toast.makeText(activity, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); + // Starting ReCaptcha Challenge Activity + Intent intent = new Intent(activity, ReCaptchaActivity.class); + intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, exception.getUrl()); + startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST); + + showError(getString(R.string.recaptcha_request_toast), false); + } + + public void onUnrecoverableError(final Throwable exception, final UserAction userAction, + final String serviceName, final String request, + @StringRes final int errorId) { + onUnrecoverableError(Collections.singletonList(exception), userAction, serviceName, + request, errorId); + } + + public void onUnrecoverableError(final List exception, final UserAction userAction, + final String serviceName, final String request, + @StringRes final int errorId) { + if (DEBUG) { + Log.d(TAG, "onUnrecoverableError() called with: exception = [" + exception + "]"); + } + + ErrorActivity.reportError(getContext(), exception, MainActivity.class, null, + ErrorActivity.ErrorInfo.make(userAction, serviceName == null ? "none" : serviceName, + request == null ? "none" : request, errorId)); + } + + public void showSnackBarError(final Throwable exception, final UserAction userAction, + final String serviceName, final String request, + @StringRes final int errorId) { + showSnackBarError(Collections.singletonList(exception), userAction, serviceName, request, + errorId); + } + + /** + * Show a SnackBar and only call + * {@link ErrorActivity#reportError(Context, List, Class, View, ErrorActivity.ErrorInfo)} + * IF we a find a valid view (otherwise the error screen appears). + * + * @param exception List of the exceptions to show + * @param userAction The user action that caused the exception + * @param serviceName The service where the exception happened + * @param request The page that was requested + * @param errorId The ID of the error + */ + public void showSnackBarError(final List exception, final UserAction userAction, + final String serviceName, final String request, + @StringRes final int errorId) { + if (DEBUG) { + Log.d(TAG, "showSnackBarError() called with: " + + "exception = [" + exception + "], userAction = [" + userAction + "], " + + "request = [" + request + "], errorId = [" + errorId + "]"); + } + View rootView = activity != null ? activity.findViewById(android.R.id.content) : null; + if (rootView == null && getView() != null) { + rootView = getView(); + } + if (rootView == null) { + return; + } + + ErrorActivity.reportError(getContext(), exception, MainActivity.class, rootView, + ErrorActivity.ErrorInfo.make(userAction, serviceName, request, errorId)); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/BlankFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/BlankFragment.java new file mode 100644 index 000000000..940d8269d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/BlankFragment.java @@ -0,0 +1,30 @@ +package org.schabi.newpipelegacy.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.BaseFragment; +import org.schabi.newpipelegacy.R; + +public class BlankFragment extends BaseFragment { + @Nullable + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + setTitle("NewPipe"); + return inflater.inflate(R.layout.fragment_blank, container, false); + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + setTitle("NewPipe"); + // leave this inline. Will make it harder for copy cats. + // If you are a Copy cat FUCK YOU. + // I WILL FIND YOU, AND I WILL ... + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/EmptyFragment.java new file mode 100644 index 000000000..08d698318 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/EmptyFragment.java @@ -0,0 +1,19 @@ +package org.schabi.newpipelegacy.fragments; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.BaseFragment; +import org.schabi.newpipelegacy.R; + +public class EmptyFragment extends BaseFragment { + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_empty, container, false); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/MainFragment.java new file mode 100644 index 000000000..805ec6602 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/MainFragment.java @@ -0,0 +1,275 @@ +package org.schabi.newpipelegacy.fragments; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.os.Bundle; +import android.preference.PreferenceManager; +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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.tabs.TabLayout; + +import org.schabi.newpipelegacy.BaseFragment; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.settings.tabs.Tab; +import org.schabi.newpipelegacy.settings.tabs.TabsManager; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ServiceHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.ScrollableTabLayout; + +import java.util.ArrayList; +import java.util.List; + +public class MainFragment extends BaseFragment implements TabLayout.OnTabSelectedListener { + private ViewPager viewPager; + private SelectedTabsPagerAdapter pagerAdapter; + private ScrollableTabLayout tabLayout; + + private List tabsList = new ArrayList<>(); + private TabsManager tabsManager; + + private boolean hasTabsChanged = false; + + private boolean previousYoutubeRestrictedModeEnabled; + private String youtubeRestrictedModeEnabledKey; + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + tabsManager = TabsManager.getManager(activity); + tabsManager.setSavedTabsListener(() -> { + if (DEBUG) { + Log.d(TAG, "TabsManager.SavedTabsChangeListener: " + + "onTabsChanged called, isResumed = " + isResumed()); + } + if (isResumed()) { + setupTabs(); + } else { + hasTabsChanged = true; + } + }); + + youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); + previousYoutubeRestrictedModeEnabled = + PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean(youtubeRestrictedModeEnabledKey, false); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_main, container, false); + } + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + tabLayout = rootView.findViewById(R.id.main_tab_layout); + viewPager = rootView.findViewById(R.id.pager); + + tabLayout.setTabIconTint(ColorStateList.valueOf( + ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent))); + tabLayout.setupWithViewPager(viewPager); + tabLayout.addOnTabSelectedListener(this); + + setupTabs(); + } + + @Override + public void onResume() { + super.onResume(); + + boolean youtubeRestrictedModeEnabled = + PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean(youtubeRestrictedModeEnabledKey, false); + if (previousYoutubeRestrictedModeEnabled != youtubeRestrictedModeEnabled) { + previousYoutubeRestrictedModeEnabled = youtubeRestrictedModeEnabled; + setupTabs(); + } else if (hasTabsChanged) { + setupTabs(); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + tabsManager.unsetSavedTabsListener(); + if (viewPager != null) { + viewPager.setAdapter(null); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + inflater.inflate(R.menu.main_fragment_menu, menu); + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayHomeAsUpEnabled(false); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_search: + try { + NavigationHelper.openSearchFragment( + getFragmentManager(), + ServiceHelper.getSelectedServiceId(activity), + ""); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + //////////////////////////////////////////////////////////////////////////*/ + + private void setupTabs() { + tabsList.clear(); + tabsList.addAll(tabsManager.getTabs()); + + if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) { + pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), + getChildFragmentManager(), tabsList); + } + + viewPager.setAdapter(null); + viewPager.setOffscreenPageLimit(tabsList.size()); + viewPager.setAdapter(pagerAdapter); + + updateTabsIconAndDescription(); + updateTitleForTab(viewPager.getCurrentItem()); + + hasTabsChanged = false; + } + + private void updateTabsIconAndDescription() { + for (int i = 0; i < tabsList.size(); i++) { + final TabLayout.Tab tabToSet = tabLayout.getTabAt(i); + if (tabToSet != null) { + final Tab tab = tabsList.get(i); + tabToSet.setIcon(tab.getTabIconRes(requireContext())); + tabToSet.setContentDescription(tab.getTabName(requireContext())); + } + } + } + + private void updateTitleForTab(final int tabPosition) { + setTitle(tabsList.get(tabPosition).getTabName(requireContext())); + } + + @Override + public void onTabSelected(final TabLayout.Tab selectedTab) { + if (DEBUG) { + Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]"); + } + updateTitleForTab(selectedTab.getPosition()); + } + + @Override + public void onTabUnselected(final TabLayout.Tab tab) { } + + @Override + public void onTabReselected(final TabLayout.Tab tab) { + if (DEBUG) { + Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]"); + } + updateTitleForTab(tab.getPosition()); + } + + private static final class SelectedTabsPagerAdapter + extends FragmentStatePagerAdapterMenuWorkaround { + private final Context context; + private final List internalTabsList; + + private SelectedTabsPagerAdapter(final Context context, + final FragmentManager fragmentManager, + final List tabsList) { + super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); + this.context = context; + this.internalTabsList = new ArrayList<>(tabsList); + } + + @NonNull + @Override + public Fragment getItem(final int position) { + final Tab tab = internalTabsList.get(position); + + Throwable throwable = null; + Fragment fragment = null; + try { + fragment = tab.getFragment(context); + } catch (ExtractionException e) { + throwable = e; + } + + if (throwable != null) { + ErrorActivity.reportError(context, throwable, null, null, ErrorActivity.ErrorInfo + .make(UserAction.UI_ERROR, "none", "", R.string.app_ui_crash)); + return new BlankFragment(); + } + + if (fragment instanceof BaseFragment) { + ((BaseFragment) fragment).useAsFrontPage(true); + } + + return fragment; + } + + @Override + public int getItemPosition(final Object object) { + // Causes adapter to reload all Fragments when + // notifyDataSetChanged is called + return POSITION_NONE; + } + + @Override + public int getCount() { + return internalTabsList.size(); + } + + public boolean sameTabs(final List tabsToCompare) { + return internalTabsList.equals(tabsToCompare); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/OnScrollBelowItemsListener.java new file mode 100644 index 000000000..9a7f09b56 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/OnScrollBelowItemsListener.java @@ -0,0 +1,48 @@ +package org.schabi.newpipelegacy.fragments; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.StaggeredGridLayoutManager; + +/** + * Recycler view scroll listener which calls the method {@link #onScrolledDown(RecyclerView)} + * if the view is scrolled below the last item. + */ +public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener { + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + super.onScrolled(recyclerView, dx, dy); + if (dy > 0) { + int pastVisibleItems = 0; + int visibleItemCount; + int totalItemCount; + RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); + + visibleItemCount = layoutManager.getChildCount(); + totalItemCount = layoutManager.getItemCount(); + + // Already covers the GridLayoutManager case + if (layoutManager instanceof LinearLayoutManager) { + pastVisibleItems = ((LinearLayoutManager) layoutManager) + .findFirstVisibleItemPosition(); + } else if (layoutManager instanceof StaggeredGridLayoutManager) { + int[] positions = ((StaggeredGridLayoutManager) layoutManager) + .findFirstVisibleItemPositions(null); + if (positions != null && positions.length > 0) { + pastVisibleItems = positions[0]; + } + } + + if ((visibleItemCount + pastVisibleItems) >= totalItemCount) { + onScrolledDown(recyclerView); + } + } + } + + /** + * Called when the recycler view is scrolled below the last item. + * + * @param recyclerView the recycler view + */ + public abstract void onScrolledDown(RecyclerView recyclerView); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/ViewContract.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/ViewContract.java new file mode 100644 index 000000000..36914020f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/ViewContract.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.fragments; + +public interface ViewContract { + void showLoading(); + + void hideLoading(); + + void showEmptyState(); + + void showError(String message, boolean showRetryButton); + + void handleResult(I result); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/StackItem.java new file mode 100644 index 000000000..604f2bbaa --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/StackItem.java @@ -0,0 +1,36 @@ +package org.schabi.newpipelegacy.fragments.detail; + +import java.io.Serializable; + +class StackItem implements Serializable { + private final int serviceId; + private final String url; + private String title; + + StackItem(final int serviceId, final String url, final String title) { + this.serviceId = serviceId; + this.url = url; + this.title = title; + } + + public int getServiceId() { + return serviceId; + } + + public String getTitle() { + return title; + } + + public void setTitle(final String title) { + this.title = title; + } + + public String getUrl() { + return url; + } + + @Override + public String toString() { + return getServiceId() + ":" + getUrl() + " > " + getTitle(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/TabAdaptor.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/TabAdaptor.java new file mode 100644 index 000000000..26c121889 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/TabAdaptor.java @@ -0,0 +1,89 @@ +package org.schabi.newpipelegacy.fragments.detail; + +import android.view.ViewGroup; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentPagerAdapter; + +import java.util.ArrayList; +import java.util.List; + +public class TabAdaptor extends FragmentPagerAdapter { + private final List mFragmentList = new ArrayList<>(); + private final List mFragmentTitleList = new ArrayList<>(); + private final FragmentManager fragmentManager; + + public TabAdaptor(final FragmentManager fm) { + super(fm); + this.fragmentManager = fm; + } + + @Override + public Fragment getItem(final int position) { + return mFragmentList.get(position); + } + + @Override + public int getCount() { + return mFragmentList.size(); + } + + public void addFragment(final Fragment fragment, final String title) { + mFragmentList.add(fragment); + mFragmentTitleList.add(title); + } + + public void clearAllItems() { + mFragmentList.clear(); + mFragmentTitleList.clear(); + } + + public void removeItem(final int position) { + mFragmentList.remove(position == 0 ? 0 : position - 1); + mFragmentTitleList.remove(position == 0 ? 0 : position - 1); + } + + public void updateItem(final int position, final Fragment fragment) { + mFragmentList.set(position, fragment); + } + + public void updateItem(final String title, final Fragment fragment) { + int index = mFragmentTitleList.indexOf(title); + if (index != -1) { + updateItem(index, fragment); + } + } + + @Override + public int getItemPosition(final Object object) { + if (mFragmentList.contains(object)) { + return mFragmentList.indexOf(object); + } else { + return POSITION_NONE; + } + } + + public int getItemPositionByTitle(final String title) { + return mFragmentTitleList.indexOf(title); + } + + @Nullable + public String getItemTitle(final int position) { + if (position < 0 || position >= mFragmentTitleList.size()) { + return null; + } + return mFragmentTitleList.get(position); + } + + public void notifyDataSetUpdate() { + notifyDataSetChanged(); + } + + @Override + public void destroyItem(final ViewGroup container, final int position, final Object object) { + fragmentManager.beginTransaction().remove((Fragment) object).commitNowAllowingStateLoss(); + } + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/VideoDetailFragment.java new file mode 100644 index 000000000..d488b68d2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/detail/VideoDetailFragment.java @@ -0,0 +1,1424 @@ +package org.schabi.newpipelegacy.fragments.detail; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.Html; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.Spinner; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.viewpager.widget.ViewPager; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.tabs.TabLayout; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; +import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.ReCaptchaActivity; +import org.schabi.newpipelegacy.download.DownloadDialog; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Description; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.fragments.BackPressable; +import org.schabi.newpipelegacy.fragments.BaseStateFragment; +import org.schabi.newpipelegacy.fragments.EmptyFragment; +import org.schabi.newpipelegacy.fragments.list.comments.CommentsFragment; +import org.schabi.newpipelegacy.fragments.list.videos.RelatedVideosFragment; +import org.schabi.newpipelegacy.local.dialog.PlaylistAppendDialog; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.player.MainVideoPlayer; +import org.schabi.newpipelegacy.player.PopupVideoPlayer; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.InfoCache; +import org.schabi.newpipelegacy.util.KoreUtil; +import org.schabi.newpipelegacy.util.ListHelper; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.PermissionHelper; +import org.schabi.newpipelegacy.util.ShareUtils; +import org.schabi.newpipelegacy.util.StreamItemAdapter; +import org.schabi.newpipelegacy.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.AnimatedProgressBar; +import org.schabi.newpipelegacy.views.LargeTextMovementMethod; + +import java.io.Serializable; +import java.util.Collection; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import icepick.State; +import io.noties.markwon.Markwon; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; +import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public class VideoDetailFragment extends BaseStateFragment + implements BackPressable, SharedPreferences.OnSharedPreferenceChangeListener, + View.OnClickListener, View.OnLongClickListener { + public static final String AUTO_PLAY = "auto_play"; + + private int updateFlags = 0; + private static final int RELATED_STREAMS_UPDATE_FLAG = 0x1; + private static final int RESOLUTIONS_MENU_UPDATE_FLAG = 0x2; + private static final int TOOLBAR_ITEMS_UPDATE_FLAG = 0x4; + private static final int COMMENTS_UPDATE_FLAG = 0x8; + + private boolean autoPlayEnabled; + private boolean showRelatedStreams; + private boolean showComments; + private String selectedTabTag; + + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + protected String name; + @State + protected String url; + + private StreamInfo currentInfo; + private Disposable currentWorker; + @NonNull + private CompositeDisposable disposables = new CompositeDisposable(); + @Nullable + private Disposable positionSubscriber = null; + + private List sortedVideoStreams; + private int selectedVideoStreamIndex = -1; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private Menu menu; + + private Spinner spinnerToolbar; + + private LinearLayout contentRootLayoutHiding; + + private View thumbnailBackgroundButton; + private ImageView thumbnailImageView; + private ImageView thumbnailPlayButton; + private AnimatedProgressBar positionView; + + private View videoTitleRoot; + private TextView videoTitleTextView; + private ImageView videoTitleToggleArrow; + private TextView videoCountView; + + private TextView detailControlsBackground; + private TextView detailControlsPopup; + private TextView detailControlsAddToPlaylist; + private TextView detailControlsDownload; + private TextView appendControlsDetail; + private TextView detailDurationView; + private TextView detailPositionView; + + private LinearLayout videoDescriptionRootLayout; + private TextView videoUploadDateView; + private TextView videoDescriptionView; + + private View uploaderRootLayout; + private TextView uploaderTextView; + private ImageView uploaderThumb; + private TextView subChannelTextView; + private ImageView subChannelThumb; + + private TextView thumbsUpTextView; + private ImageView thumbsUpImageView; + private TextView thumbsDownTextView; + private ImageView thumbsDownImageView; + private TextView thumbsDisabledTextView; + + private AppBarLayout appBarLayout; + private ViewPager viewPager; + private TabAdaptor pageAdapter; + private TabLayout tabLayout; + private FrameLayout relatedStreamsLayout; + + /*////////////////////////////////////////////////////////////////////////*/ + + private static final String COMMENTS_TAB_TAG = "COMMENTS"; + private static final String RELATED_TAB_TAG = "NEXT VIDEO"; + private static final String EMPTY_TAB_TAG = "EMPTY TAB"; + + private static final String INFO_KEY = "info_key"; + private static final String STACK_KEY = "stack_key"; + + /** + * Stack that contains the "navigation history".
+ * The peek is the current video. + */ + private final LinkedList stack = new LinkedList<>(); + + public static VideoDetailFragment getInstance(final int serviceId, final String videoUrl, + final String name) { + VideoDetailFragment instance = new VideoDetailFragment(); + instance.setInitialData(serviceId, videoUrl, name); + return instance; + } + + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + + showRelatedStreams = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(getString(R.string.show_next_video_key), true); + + showComments = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(getString(R.string.show_comments_key), true); + + selectedTabTag = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); + + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_video_detail, container, false); + } + + @Override + public void onPause() { + super.onPause(); + if (currentWorker != null) { + currentWorker.dispose(); + } + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit() + .putString(getString(R.string.stream_info_selected_tab_key), + pageAdapter.getItemTitle(viewPager.getCurrentItem())) + .apply(); + } + + @Override + public void onResume() { + super.onResume(); + + if (updateFlags != 0) { + if (!isLoading.get() && currentInfo != null) { + if ((updateFlags & RELATED_STREAMS_UPDATE_FLAG) != 0) { + startLoading(false); + } + if ((updateFlags & RESOLUTIONS_MENU_UPDATE_FLAG) != 0) { + setupActionBar(currentInfo); + } + if ((updateFlags & COMMENTS_UPDATE_FLAG) != 0) { + startLoading(false); + } + } + + if ((updateFlags & TOOLBAR_ITEMS_UPDATE_FLAG) != 0 + && menu != null) { + updateMenuItemVisibility(); + } + + updateFlags = 0; + } + + // Check if it was loading when the fragment was stopped/paused, + if (wasLoading.getAndSet(false)) { + selectAndLoadVideo(serviceId, url, name); + } else if (currentInfo != null) { + updateProgressInfo(currentInfo); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(this); + + if (positionSubscriber != null) { + positionSubscriber.dispose(); + } + if (currentWorker != null) { + currentWorker.dispose(); + } + if (disposables != null) { + disposables.clear(); + } + positionSubscriber = null; + currentWorker = null; + disposables = null; + } + + @Override + public void onDestroyView() { + if (DEBUG) { + Log.d(TAG, "onDestroyView() called"); + } + spinnerToolbar.setOnItemSelectedListener(null); + spinnerToolbar.setAdapter(null); + super.onDestroyView(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + switch (requestCode) { + case ReCaptchaActivity.RECAPTCHA_REQUEST: + if (resultCode == Activity.RESULT_OK) { + NavigationHelper + .openVideoDetailFragment(getFragmentManager(), serviceId, url, name); + } else { + Log.e(TAG, "ReCaptcha failed"); + } + break; + default: + Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); + break; + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(getString(R.string.show_next_video_key))) { + showRelatedStreams = sharedPreferences.getBoolean(key, true); + updateFlags |= RELATED_STREAMS_UPDATE_FLAG; + } else if (key.equals(getString(R.string.default_video_format_key)) + || key.equals(getString(R.string.default_resolution_key)) + || key.equals(getString(R.string.show_higher_resolutions_key)) + || key.equals(getString(R.string.use_external_video_player_key))) { + updateFlags |= RESOLUTIONS_MENU_UPDATE_FLAG; + } else if (key.equals(getString(R.string.show_play_with_kodi_key))) { + updateFlags |= TOOLBAR_ITEMS_UPDATE_FLAG; + } else if (key.equals(getString(R.string.show_comments_key))) { + showComments = sharedPreferences.getBoolean(key, true); + updateFlags |= COMMENTS_UPDATE_FLAG; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + + if (!isLoading.get() && currentInfo != null && isVisible()) { + outState.putSerializable(INFO_KEY, currentInfo); + } + + outState.putSerializable(STACK_KEY, stack); + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedState) { + super.onRestoreInstanceState(savedState); + + Serializable serializable = savedState.getSerializable(INFO_KEY); + if (serializable instanceof StreamInfo) { + //noinspection unchecked + currentInfo = (StreamInfo) serializable; + InfoCache.getInstance().putInfo(serviceId, url, currentInfo, InfoItem.InfoType.STREAM); + } + + serializable = savedState.getSerializable(STACK_KEY); + if (serializable instanceof Collection) { + //noinspection unchecked + stack.addAll((Collection) serializable); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onClick(final View v) { + if (isLoading.get() || currentInfo == null) { + return; + } + + switch (v.getId()) { + case R.id.detail_controls_background: + openBackgroundPlayer(false); + break; + case R.id.detail_controls_popup: + openPopupPlayer(false); + break; + case R.id.detail_controls_playlist_append: + if (getFragmentManager() != null && currentInfo != null) { + PlaylistAppendDialog.fromStreamInfo(currentInfo) + .show(getFragmentManager(), TAG); + } + break; + case R.id.detail_controls_download: + if (PermissionHelper.checkStoragePermissions(activity, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + this.openDownloadDialog(); + } + break; + case R.id.detail_uploader_root_layout: + if (TextUtils.isEmpty(currentInfo.getSubChannelUrl())) { + if (!TextUtils.isEmpty(currentInfo.getUploaderUrl())) { + openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); + } + + if (DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL"); + } + } else { + openChannel(currentInfo.getSubChannelUrl(), + currentInfo.getSubChannelName()); + } + break; + case R.id.detail_thumbnail_root_layout: + if (currentInfo.getVideoStreams().isEmpty() + && currentInfo.getVideoOnlyStreams().isEmpty()) { + openBackgroundPlayer(false); + } else { + openVideoPlayer(); + } + break; + case R.id.detail_title_root_layout: + toggleTitleAndDescription(); + break; + } + } + + private void openChannel(final String subChannelUrl, final String subChannelName) { + try { + NavigationHelper.openChannelFragment( + getFragmentManager(), + currentInfo.getServiceId(), + subChannelUrl, + subChannelName); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + } + + @Override + public boolean onLongClick(final View v) { + if (isLoading.get() || currentInfo == null) { + return false; + } + + switch (v.getId()) { + case R.id.detail_controls_background: + openBackgroundPlayer(true); + break; + case R.id.detail_controls_popup: + openPopupPlayer(true); + break; + case R.id.detail_controls_download: + NavigationHelper.openDownloads(getActivity()); + break; + case R.id.detail_uploader_root_layout: + if (TextUtils.isEmpty(currentInfo.getSubChannelUrl())) { + Log.w(TAG, + "Can't open parent channel because we got no parent channel URL"); + } else { + openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); + } + break; + case R.id.detail_title_root_layout: + ShareUtils.copyToClipboard(getContext(), videoTitleTextView.getText().toString()); + break; + } + + return true; + } + + private void toggleTitleAndDescription() { + if (videoDescriptionRootLayout.getVisibility() == View.VISIBLE) { + videoTitleTextView.setMaxLines(1); + videoDescriptionRootLayout.setVisibility(View.GONE); + videoDescriptionView.setFocusable(false); + videoTitleToggleArrow.setImageResource( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_expand_more)); + } else { + videoTitleTextView.setMaxLines(10); + videoDescriptionRootLayout.setVisibility(View.VISIBLE); + videoDescriptionView.setFocusable(true); + videoDescriptionView.setMovementMethod(new LargeTextMovementMethod()); + videoTitleToggleArrow.setImageResource( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_expand_less)); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + spinnerToolbar = activity.findViewById(R.id.toolbar).findViewById(R.id.toolbar_spinner); + + thumbnailBackgroundButton = rootView.findViewById(R.id.detail_thumbnail_root_layout); + thumbnailImageView = rootView.findViewById(R.id.detail_thumbnail_image_view); + thumbnailPlayButton = rootView.findViewById(R.id.detail_thumbnail_play_button); + + contentRootLayoutHiding = rootView.findViewById(R.id.detail_content_root_hiding); + + videoTitleRoot = rootView.findViewById(R.id.detail_title_root_layout); + videoTitleTextView = rootView.findViewById(R.id.detail_video_title_view); + videoTitleToggleArrow = rootView.findViewById(R.id.detail_toggle_description_view); + videoCountView = rootView.findViewById(R.id.detail_view_count_view); + positionView = rootView.findViewById(R.id.position_view); + + detailControlsBackground = rootView.findViewById(R.id.detail_controls_background); + detailControlsPopup = rootView.findViewById(R.id.detail_controls_popup); + detailControlsAddToPlaylist = rootView.findViewById(R.id.detail_controls_playlist_append); + detailControlsDownload = rootView.findViewById(R.id.detail_controls_download); + appendControlsDetail = rootView.findViewById(R.id.touch_append_detail); + detailDurationView = rootView.findViewById(R.id.detail_duration_view); + detailPositionView = rootView.findViewById(R.id.detail_position_view); + + videoDescriptionRootLayout = rootView.findViewById(R.id.detail_description_root_layout); + videoUploadDateView = rootView.findViewById(R.id.detail_upload_date_view); + videoDescriptionView = rootView.findViewById(R.id.detail_description_view); + + thumbsUpTextView = rootView.findViewById(R.id.detail_thumbs_up_count_view); + thumbsUpImageView = rootView.findViewById(R.id.detail_thumbs_up_img_view); + thumbsDownTextView = rootView.findViewById(R.id.detail_thumbs_down_count_view); + thumbsDownImageView = rootView.findViewById(R.id.detail_thumbs_down_img_view); + thumbsDisabledTextView = rootView.findViewById(R.id.detail_thumbs_disabled_view); + + uploaderRootLayout = rootView.findViewById(R.id.detail_uploader_root_layout); + uploaderTextView = rootView.findViewById(R.id.detail_uploader_text_view); + uploaderThumb = rootView.findViewById(R.id.detail_uploader_thumbnail_view); + subChannelTextView = rootView.findViewById(R.id.detail_sub_channel_text_view); + subChannelThumb = rootView.findViewById(R.id.detail_sub_channel_thumbnail_view); + + appBarLayout = rootView.findViewById(R.id.appbarlayout); + viewPager = rootView.findViewById(R.id.viewpager); + pageAdapter = new TabAdaptor(getChildFragmentManager()); + viewPager.setAdapter(pageAdapter); + tabLayout = rootView.findViewById(R.id.tablayout); + tabLayout.setupWithViewPager(viewPager); + + relatedStreamsLayout = rootView.findViewById(R.id.relatedStreamsLayout); + + setHeightThumbnail(); + + thumbnailBackgroundButton.requestFocus(); + + if (AndroidTvUtils.isTv(getContext())) { + // remove ripple effects from detail controls + final int transparent = getResources().getColor(R.color.transparent_background_color); + detailControlsAddToPlaylist.setBackgroundColor(transparent); + detailControlsBackground.setBackgroundColor(transparent); + detailControlsPopup.setBackgroundColor(transparent); + detailControlsDownload.setBackgroundColor(transparent); + } + + } + + @Override + protected void initListeners() { + super.initListeners(); + + videoTitleRoot.setOnLongClickListener(this); + uploaderRootLayout.setOnClickListener(this); + uploaderRootLayout.setOnLongClickListener(this); + videoTitleRoot.setOnClickListener(this); + thumbnailBackgroundButton.setOnClickListener(this); + detailControlsBackground.setOnClickListener(this); + detailControlsPopup.setOnClickListener(this); + detailControlsAddToPlaylist.setOnClickListener(this); + detailControlsDownload.setOnClickListener(this); + detailControlsDownload.setOnLongClickListener(this); + + detailControlsBackground.setLongClickable(true); + detailControlsPopup.setLongClickable(true); + detailControlsBackground.setOnLongClickListener(this); + detailControlsPopup.setOnLongClickListener(this); + detailControlsBackground.setOnTouchListener(getOnControlsTouchListener()); + detailControlsPopup.setOnTouchListener(getOnControlsTouchListener()); + } + + private View.OnTouchListener getOnControlsTouchListener() { + return (View view, MotionEvent motionEvent) -> { + if (!PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(getString(R.string.show_hold_to_append_key), true)) { + return false; + } + + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { + animateView(appendControlsDetail, true, 250, 0, () -> + animateView(appendControlsDetail, false, 1500, 1000)); + } + return false; + }; + } + + private void initThumbnailViews(@NonNull final StreamInfo info) { + thumbnailImageView.setImageResource(R.drawable.dummy_thumbnail_dark); + if (!TextUtils.isEmpty(info.getThumbnailUrl())) { + final String infoServiceName = NewPipe.getNameOfService(info.getServiceId()); + final ImageLoadingListener onFailListener = new SimpleImageLoadingListener() { + @Override + public void onLoadingFailed(final String imageUri, final View view, + final FailReason failReason) { + showSnackBarError(failReason.getCause(), UserAction.LOAD_IMAGE, + infoServiceName, imageUri, R.string.could_not_load_thumbnails); + } + }; + + IMAGE_LOADER.displayImage(info.getThumbnailUrl(), thumbnailImageView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS, onFailListener); + } + + if (!TextUtils.isEmpty(info.getSubChannelAvatarUrl())) { + IMAGE_LOADER.displayImage(info.getSubChannelAvatarUrl(), subChannelThumb, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + } + + if (!TextUtils.isEmpty(info.getUploaderAvatarUrl())) { + IMAGE_LOADER.displayImage(info.getUploaderAvatarUrl(), uploaderThumb, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu m, final MenuInflater inflater) { + this.menu = m; + + // CAUTION set item properties programmatically otherwise it would not be accepted by + // appcompat itemsinflater.inflate(R.menu.videoitem_detail, menu); + + inflater.inflate(R.menu.video_detail_menu, m); + + updateMenuItemVisibility(); + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayHomeAsUpEnabled(true); + supportActionBar.setDisplayShowTitleEnabled(false); + } + } + + private void updateMenuItemVisibility() { + // show kodi button if it supports the current service and it is enabled in settings + menu.findItem(R.id.action_play_with_kodi).setVisible( + KoreUtil.isServiceSupportedByKore(serviceId) + && PreferenceManager.getDefaultSharedPreferences(activity).getBoolean( + activity.getString(R.string.show_play_with_kodi_key), false)); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + if (id == R.id.action_settings) { + NavigationHelper.openSettings(requireContext()); + return true; + } + + if (isLoading.get()) { + // if still loading, block menu buttons related to video info + return true; + } + + switch (id) { + case R.id.menu_item_share: + if (currentInfo != null) { + ShareUtils.shareUrl(requireContext(), currentInfo.getName(), + currentInfo.getOriginalUrl()); + } + return true; + case R.id.menu_item_openInBrowser: + if (currentInfo != null) { + ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getOriginalUrl()); + } + return true; + case R.id.action_play_with_kodi: + try { + NavigationHelper.playWithKore(activity, Uri.parse(currentInfo.getUrl())); + } catch (Exception e) { + if (DEBUG) { + Log.i(TAG, "Failed to start kore", e); + } + KoreUtil.showInstallKoreDialog(activity); + } + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + private void setupActionBarOnError(final String u) { + if (DEBUG) { + Log.d(TAG, "setupActionBarHandlerOnError() called with: url = [" + u + "]"); + } + Log.e("-----", "missing code"); + } + + private void setupActionBar(final StreamInfo info) { + if (DEBUG) { + Log.d(TAG, "setupActionBarHandler() called with: info = [" + info + "]"); + } + boolean isExternalPlayerEnabled = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_video_player_key), false); + + sortedVideoStreams = ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), + info.getVideoOnlyStreams(), false); + selectedVideoStreamIndex = ListHelper + .getDefaultResolutionIndex(activity, sortedVideoStreams); + + final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>( + activity, new StreamSizeWrapper<>(sortedVideoStreams, activity), + isExternalPlayerEnabled); + spinnerToolbar.setAdapter(streamsAdapter); + spinnerToolbar.setSelection(selectedVideoStreamIndex); + spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(final AdapterView parent, final View view, + final int position, final long id) { + selectedVideoStreamIndex = position; + } + + @Override + public void onNothingSelected(final AdapterView parent) { } + }); + } + + /*////////////////////////////////////////////////////////////////////////// + // OwnStack + //////////////////////////////////////////////////////////////////////////*/ + + private void pushToStack(final int sid, final String videoUrl, final String title) { + if (DEBUG) { + Log.d(TAG, "pushToStack() called with: serviceId = [" + + sid + "], videoUrl = [" + videoUrl + "], title = [" + title + "]"); + } + + if (stack.size() > 0 + && stack.peek().getServiceId() == sid + && stack.peek().getUrl().equals(videoUrl)) { + Log.d(TAG, "pushToStack() called with: serviceId == peek.serviceId = [" + + sid + "], videoUrl == peek.getUrl = [" + videoUrl + "]"); + return; + } else { + Log.d(TAG, "pushToStack() wasn't equal"); + } + + stack.push(new StackItem(sid, videoUrl, title)); + } + + private void setTitleToUrl(final int sid, final String videoUrl, final String title) { + if (title != null && !title.isEmpty()) { + for (StackItem stackItem : stack) { + if (stack.peek().getServiceId() == sid + && stackItem.getUrl().equals(videoUrl)) { + stackItem.setTitle(title); + } + } + } + } + + @Override + public boolean onBackPressed() { + if (DEBUG) { + Log.d(TAG, "onBackPressed() called"); + } + // That means that we are on the start of the stack, + // return false to let the MainActivity handle the onBack + if (stack.size() <= 1) { + return false; + } + // Remove top + stack.pop(); + // Get stack item from the new top + StackItem peek = stack.peek(); + + selectAndLoadVideo(peek.getServiceId(), peek.getUrl(), + !TextUtils.isEmpty(peek.getTitle()) ? peek.getTitle() : ""); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void doInitialLoadLogic() { + if (currentInfo == null) { + prepareAndLoadInfo(); + } else { + prepareAndHandleInfo(currentInfo, false); + } + } + + public void selectAndLoadVideo(final int sid, final String videoUrl, final String title) { + setInitialData(sid, videoUrl, title); + prepareAndLoadInfo(); + } + + private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { + if (DEBUG) { + Log.d(TAG, "prepareAndHandleInfo() called with: " + + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); + } + + setInitialData(info.getServiceId(), info.getUrl(), info.getName()); + pushToStack(serviceId, url, name); + showLoading(); + initTabs(); + + if (scrollToTop) { + appBarLayout.setExpanded(true, true); + } + handleResult(info); + showContent(); + + } + + private void prepareAndLoadInfo() { + appBarLayout.setExpanded(true, true); + pushToStack(serviceId, url, name); + startLoading(false); + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + initTabs(); + currentInfo = null; + if (currentWorker != null) { + currentWorker.dispose(); + } + + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + + currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((@NonNull final StreamInfo result) -> { + isLoading.set(false); + if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( + getString(R.string.show_age_restricted_content), false)) { + hideAgeRestrictedContent(); + } else { + currentInfo = result; + handleResult(result); + showContent(); + } + }, (@NonNull final Throwable throwable) -> { + isLoading.set(false); + onError(throwable); + }); + } + + private void initTabs() { + if (pageAdapter.getCount() != 0) { + selectedTabTag = pageAdapter.getItemTitle(viewPager.getCurrentItem()); + } + pageAdapter.clearAllItems(); + + if (shouldShowComments()) { + pageAdapter.addFragment(CommentsFragment.getInstance(serviceId, url, name), + COMMENTS_TAB_TAG); + } + + if (showRelatedStreams && null == relatedStreamsLayout) { + //temp empty fragment. will be updated in handleResult + pageAdapter.addFragment(new Fragment(), RELATED_TAB_TAG); + } + + if (pageAdapter.getCount() == 0) { + pageAdapter.addFragment(new EmptyFragment(), EMPTY_TAB_TAG); + } + + pageAdapter.notifyDataSetUpdate(); + + if (pageAdapter.getCount() < 2) { + tabLayout.setVisibility(View.GONE); + } else { + int position = pageAdapter.getItemPositionByTitle(selectedTabTag); + if (position != -1) { + viewPager.setCurrentItem(position); + } + tabLayout.setVisibility(View.VISIBLE); + } + } + + private boolean shouldShowComments() { + try { + return showComments && NewPipe.getService(serviceId) + .getServiceInfo() + .getMediaCapabilities() + .contains(COMMENTS); + } catch (ExtractionException e) { + return false; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Play Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void openBackgroundPlayer(final boolean append) { + AudioStream audioStream = currentInfo.getAudioStreams() + .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); + + boolean useExternalAudioPlayer = PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); + + if (!useExternalAudioPlayer && android.os.Build.VERSION.SDK_INT >= 16) { + openNormalBackgroundPlayer(append); + } else { + startOnExternalPlayer(activity, currentInfo, audioStream); + } + } + + private void openPopupPlayer(final boolean append) { + if (!PermissionHelper.isPopupEnabled(activity)) { + PermissionHelper.showPopupEnablementToast(activity); + return; + } + + final PlayQueue itemQueue = new SinglePlayQueue(currentInfo); + if (append) { + NavigationHelper.enqueueOnPopupPlayer(activity, itemQueue, false); + } else { + Toast.makeText(activity, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); + final Intent intent = NavigationHelper.getPlayerIntent(activity, + PopupVideoPlayer.class, itemQueue, getSelectedVideoStream().resolution, true); + activity.startService(intent); + } + } + + private void openVideoPlayer() { + VideoStream selectedVideoStream = getSelectedVideoStream(); + + if (PreferenceManager.getDefaultSharedPreferences(activity) + .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { + startOnExternalPlayer(activity, currentInfo, selectedVideoStream); + } else { + openNormalPlayer(); + } + } + + private void openNormalBackgroundPlayer(final boolean append) { + final PlayQueue itemQueue = new SinglePlayQueue(currentInfo); + if (append) { + NavigationHelper.enqueueOnBackgroundPlayer(activity, itemQueue, false); + } else { + NavigationHelper.playOnBackgroundPlayer(activity, itemQueue, true); + } + } + + private void openNormalPlayer() { + Intent mIntent; + final PlayQueue playQueue = new SinglePlayQueue(currentInfo); + mIntent = NavigationHelper.getPlayerIntent(activity, + MainVideoPlayer.class, + playQueue, + getSelectedVideoStream().getResolution(), true); + startActivity(mIntent); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + public void setAutoplay(final boolean autoplay) { + this.autoPlayEnabled = autoplay; + } + + private void startOnExternalPlayer(@NonNull final Context context, + @NonNull final StreamInfo info, + @NonNull final Stream selectedStream) { + NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), + currentInfo.getSubChannelName(), selectedStream); + + final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + disposables.add(recordManager.onViewed(info).onErrorComplete() + .subscribe( + ignored -> { /* successful */ }, + error -> Log.e(TAG, "Register view failure: ", error) + )); + } + + @Nullable + private VideoStream getSelectedVideoStream() { + return sortedVideoStreams != null ? sortedVideoStreams.get(selectedVideoStreamIndex) : null; + } + + private void prepareDescription(final Description description) { + if (description == null || TextUtils.isEmpty(description.getContent()) + || description == Description.emptyDescription) { + return; + } + + if (description.getType() == Description.HTML) { + disposables.add(Single.just(description.getContent()) + .map((@NonNull String descriptionText) -> { + Spanned parsedDescription; + if (Build.VERSION.SDK_INT >= 24) { + parsedDescription = Html.fromHtml(descriptionText, 0); + } else { + //noinspection deprecation + parsedDescription = Html.fromHtml(descriptionText); + } + return parsedDescription; + }) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((@NonNull Spanned spanned) -> { + videoDescriptionView.setText(spanned); + videoDescriptionView.setVisibility(View.VISIBLE); + })); + } else if (description.getType() == Description.MARKDOWN) { + final Markwon markwon = Markwon.builder(getContext()) + .usePlugin(LinkifyPlugin.create()) + .build(); + markwon.setMarkdown(videoDescriptionView, description.getContent()); + videoDescriptionView.setVisibility(View.VISIBLE); + } else { + //== Description.PLAIN_TEXT + videoDescriptionView.setAutoLinkMask(Linkify.WEB_URLS); + videoDescriptionView.setText(description.getContent(), TextView.BufferType.SPANNABLE); + videoDescriptionView.setVisibility(View.VISIBLE); + } + } + + private void setHeightThumbnail() { + final DisplayMetrics metrics = getResources().getDisplayMetrics(); + boolean isPortrait = metrics.heightPixels > metrics.widthPixels; + int height = isPortrait + ? (int) (metrics.widthPixels / (16.0f / 9.0f)) + : (int) (metrics.heightPixels / 2f); + thumbnailImageView.setLayoutParams( + new FrameLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, height)); + thumbnailImageView.setMinimumHeight(height); + } + + private void showContent() { + contentRootLayoutHiding.setVisibility(View.VISIBLE); + } + + protected void setInitialData(final int sid, final String u, final String title) { + this.serviceId = sid; + this.url = u; + this.name = !TextUtils.isEmpty(title) ? title : ""; + } + + private void setErrorImage(final int imageResource) { + if (thumbnailImageView == null || activity == null) { + return; + } + + thumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), imageResource)); + animateView(thumbnailImageView, false, 0, 0, + () -> animateView(thumbnailImageView, true, 500)); + } + + @Override + public void showError(final String message, final boolean showRetryButton) { + showError(message, showRetryButton, R.drawable.not_available_monkey); + } + + protected void showError(final String message, final boolean showRetryButton, + @DrawableRes final int imageError) { + super.showError(message, showRetryButton); + setErrorImage(imageError); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + + super.showLoading(); + + //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required + if (!ExtractorHelper.isCached(serviceId, url, InfoItem.InfoType.STREAM)) { + contentRootLayoutHiding.setVisibility(View.INVISIBLE); + } + + animateView(spinnerToolbar, false, 200); + animateView(thumbnailPlayButton, false, 50); + animateView(detailDurationView, false, 100); + animateView(detailPositionView, false, 100); + animateView(positionView, false, 50); + + videoTitleTextView.setText(name != null ? name : ""); + videoTitleTextView.setMaxLines(1); + animateView(videoTitleTextView, true, 0); + + videoDescriptionRootLayout.setVisibility(View.GONE); + videoTitleToggleArrow.setVisibility(View.GONE); + videoTitleRoot.setClickable(false); + + if (relatedStreamsLayout != null) { + if (showRelatedStreams) { + relatedStreamsLayout.setVisibility(View.INVISIBLE); + } else { + relatedStreamsLayout.setVisibility(View.GONE); + } + } + + IMAGE_LOADER.cancelDisplayTask(thumbnailImageView); + IMAGE_LOADER.cancelDisplayTask(subChannelThumb); + thumbnailImageView.setImageBitmap(null); + subChannelThumb.setImageBitmap(null); + } + + @Override + public void handleResult(@NonNull final StreamInfo info) { + super.handleResult(info); + + setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName()); + + if (showRelatedStreams) { + 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); + + animateView(thumbnailPlayButton, true, 200); + videoTitleTextView.setText(name); + + if (!TextUtils.isEmpty(info.getSubChannelName())) { + displayBothUploaderAndSubChannel(info); + } else if (!TextUtils.isEmpty(info.getUploaderName())) { + displayUploaderAsSubChannel(info); + } else { + uploaderTextView.setVisibility(View.GONE); + uploaderThumb.setVisibility(View.GONE); + } + + Drawable buddyDrawable = AppCompatResources.getDrawable(activity, R.drawable.buddy); + subChannelThumb.setImageDrawable(buddyDrawable); + uploaderThumb.setImageDrawable(buddyDrawable); + + if (info.getViewCount() >= 0) { + if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + videoCountView.setText(Localization.listeningCount(activity, info.getViewCount())); + } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { + videoCountView.setText(Localization + .localizeWatchingCount(activity, info.getViewCount())); + } else { + videoCountView.setText(Localization + .localizeViewCount(activity, info.getViewCount())); + } + videoCountView.setVisibility(View.VISIBLE); + } else { + videoCountView.setVisibility(View.GONE); + } + + if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { + thumbsDownImageView.setVisibility(View.VISIBLE); + thumbsUpImageView.setVisibility(View.VISIBLE); + thumbsUpTextView.setVisibility(View.GONE); + thumbsDownTextView.setVisibility(View.GONE); + + thumbsDisabledTextView.setVisibility(View.VISIBLE); + } else { + if (info.getDislikeCount() >= 0) { + thumbsDownTextView.setText(Localization + .shortCount(activity, info.getDislikeCount())); + thumbsDownTextView.setVisibility(View.VISIBLE); + thumbsDownImageView.setVisibility(View.VISIBLE); + } else { + thumbsDownTextView.setVisibility(View.GONE); + thumbsDownImageView.setVisibility(View.GONE); + } + + if (info.getLikeCount() >= 0) { + thumbsUpTextView.setText(Localization.shortCount(activity, info.getLikeCount())); + thumbsUpTextView.setVisibility(View.VISIBLE); + thumbsUpImageView.setVisibility(View.VISIBLE); + } else { + thumbsUpTextView.setVisibility(View.GONE); + thumbsUpImageView.setVisibility(View.GONE); + } + thumbsDisabledTextView.setVisibility(View.GONE); + } + + if (info.getDuration() > 0) { + detailDurationView.setText(Localization.getDurationString(info.getDuration())); + detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.duration_background_color)); + animateView(detailDurationView, true, 100); + } else if (info.getStreamType() == StreamType.LIVE_STREAM) { + detailDurationView.setText(R.string.duration_live); + detailDurationView.setBackgroundColor( + ContextCompat.getColor(activity, R.color.live_duration_background_color)); + animateView(detailDurationView, true, 100); + } else { + detailDurationView.setVisibility(View.GONE); + } + + videoDescriptionView.setVisibility(View.GONE); + videoTitleRoot.setClickable(true); + videoTitleToggleArrow.setImageResource( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_expand_more)); + videoTitleToggleArrow.setVisibility(View.VISIBLE); + videoDescriptionRootLayout.setVisibility(View.GONE); + + if (info.getUploadDate() != null) { + videoUploadDateView.setText(Localization + .localizeUploadDate(activity, info.getUploadDate().date().getTime())); + videoUploadDateView.setVisibility(View.VISIBLE); + } else { + videoUploadDateView.setText(null); + videoUploadDateView.setVisibility(View.GONE); + } + + prepareDescription(info.getDescription()); + updateProgressInfo(info); + + animateView(spinnerToolbar, true, 500); + setupActionBar(info); + initThumbnailViews(info); + + setTitleToUrl(info.getServiceId(), info.getUrl(), info.getName()); + setTitleToUrl(info.getServiceId(), info.getOriginalUrl(), info.getName()); + + if (!info.getErrors().isEmpty()) { + showSnackBarError(info.getErrors(), + UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(info.getServiceId()), + info.getUrl(), + 0); + } + + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + detailControlsDownload.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + break; + default: + if (info.getAudioStreams().isEmpty()) { + detailControlsBackground.setVisibility(View.GONE); + } + if (!info.getVideoStreams().isEmpty() || !info.getVideoOnlyStreams().isEmpty()) { + break; + } + + detailControlsPopup.setVisibility(View.GONE); + spinnerToolbar.setVisibility(View.GONE); + thumbnailPlayButton.setImageResource(R.drawable.ic_headset_shadow); + break; + } + + if (autoPlayEnabled) { + openVideoPlayer(); + // Only auto play in the first open + autoPlayEnabled = false; + } + } + + private void hideAgeRestrictedContent() { + showError(getString(R.string.restricted_video), false); + + if (relatedStreamsLayout != null) { // tablet + relatedStreamsLayout.setVisibility(View.INVISIBLE); + } + + viewPager.setVisibility(View.GONE); + tabLayout.setVisibility(View.GONE); + } + + private void displayUploaderAsSubChannel(final StreamInfo info) { + subChannelTextView.setText(info.getUploaderName()); + subChannelTextView.setVisibility(View.VISIBLE); + subChannelTextView.setSelected(true); + uploaderTextView.setVisibility(View.GONE); + } + + private void displayBothUploaderAndSubChannel(final StreamInfo info) { + subChannelTextView.setText(info.getSubChannelName()); + subChannelTextView.setVisibility(View.VISIBLE); + subChannelTextView.setSelected(true); + + subChannelThumb.setVisibility(View.VISIBLE); + + if (!TextUtils.isEmpty(info.getUploaderName())) { + uploaderTextView.setText( + String.format(getString(R.string.video_detail_by), info.getUploaderName())); + uploaderTextView.setVisibility(View.VISIBLE); + uploaderTextView.setSelected(true); + } else { + uploaderTextView.setVisibility(View.GONE); + } + } + + + public void openDownloadDialog() { + try { + DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo); + downloadDialog.setVideoStreams(sortedVideoStreams); + downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); + downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); + downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); + + downloadDialog.show(getActivity().getSupportFragmentManager(), "downloadDialog"); + } catch (Exception e) { + ErrorActivity.ErrorInfo info = ErrorActivity.ErrorInfo.make(UserAction.UI_ERROR, + ServiceList.all() + .get(currentInfo + .getServiceId()) + .getServiceInfo() + .getName(), "", + R.string.could_not_setup_download_menu); + + ErrorActivity.reportError(getActivity(), + e, + getActivity().getClass(), + getActivity().findViewById(android.R.id.content), info); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + int errorId = exception instanceof YoutubeStreamExtractor.DecryptException + ? R.string.youtube_signature_decryption_error + : exception instanceof ExtractionException + ? R.string.parsing_error + : R.string.general_error; + + onUnrecoverableError(exception, UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(serviceId), url, errorId); + + return true; + } + + private void updateProgressInfo(@NonNull final StreamInfo info) { + if (positionSubscriber != null) { + positionSubscriber.dispose(); + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); + final boolean playbackResumeEnabled = prefs + .getBoolean(activity.getString(R.string.enable_watch_history_key), true) + && prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true); + + if (!playbackResumeEnabled || info.getDuration() <= 0) { + positionView.setVisibility(View.INVISIBLE); + detailPositionView.setVisibility(View.GONE); + + // TODO: Remove this check when separation of concerns is done. + // (live streams weren't getting updated because they are mixed) + if (!info.getStreamType().equals(StreamType.LIVE_STREAM) + && !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + return; + } + } + final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); + + // TODO: Separate concerns when updating database data. + // (move the updating part to when the loading happens) + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(state -> { + final int seconds + = (int) TimeUnit.MILLISECONDS.toSeconds(state.getProgressTime()); + positionView.setMax((int) info.getDuration()); + positionView.setProgressAnimated(seconds); + detailPositionView.setText(Localization.getDurationString(seconds)); + animateView(positionView, true, 500); + animateView(detailPositionView, true, 500); + }, e -> { + if (DEBUG) { + e.printStackTrace(); + } + }, () -> { + animateView(positionView, false, 500); + animateView(detailPositionView, false, 500); + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListFragment.java new file mode 100644 index 000000000..a8e6f7b80 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListFragment.java @@ -0,0 +1,452 @@ +package org.schabi.newpipelegacy.fragments.list; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.fragments.BaseStateFragment; +import org.schabi.newpipelegacy.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipelegacy.info_list.InfoItemDialog; +import org.schabi.newpipelegacy.info_list.InfoListAdapter; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.OnClickGesture; +import org.schabi.newpipelegacy.util.StateSaver; +import org.schabi.newpipelegacy.util.StreamDialogEntry; +import org.schabi.newpipelegacy.views.SuperScrollLayoutManager; + +import java.util.List; +import java.util.Queue; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public abstract class BaseListFragment extends BaseStateFragment + implements ListViewContract, StateSaver.WriteRead, + SharedPreferences.OnSharedPreferenceChangeListener { + private static final int LIST_MODE_UPDATE_FLAG = 0x32; + protected StateSaver.SavedState savedState; + + private boolean useDefaultStateSaving = true; + private int updateFlags = 0; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + protected InfoListAdapter infoListAdapter; + protected RecyclerView itemsList; + private int focusedPosition = -1; + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + + if (infoListAdapter == null) { + infoListAdapter = new InfoListAdapter(activity); + } + } + + @Override + public void onDetach() { + super.onDetach(); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (useDefaultStateSaving) { + 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.setUseGridVariant(useGrid); + infoListAdapter.notifyDataSetChanged(); + } + updateFlags = 0; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + /** + * If the default implementation of {@link StateSaver.WriteRead} should be used. + * + * @see StateSaver + * @param useDefaultStateSaving Whether the default implementation should be used + */ + public void setUseDefaultStateSaving(final boolean useDefaultStateSaving) { + this.useDefaultStateSaving = useDefaultStateSaving; + } + + @Override + public String generateSuffix() { + // Naive solution, but it's good for now (the items don't change) + return "." + infoListAdapter.getItemsList().size() + ".list"; + } + + private int getFocusedPosition() { + try { + final View focusedItem = itemsList.getFocusedChild(); + final RecyclerView.ViewHolder itemHolder = + itemsList.findContainingViewHolder(focusedItem); + return itemHolder.getAdapterPosition(); + } catch (NullPointerException e) { + return -1; + } + } + + @Override + public void writeTo(final Queue objectsToSave) { + if (!useDefaultStateSaving) { + return; + } + + objectsToSave.add(infoListAdapter.getItemsList()); + objectsToSave.add(getFocusedPosition()); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + if (!useDefaultStateSaving) { + return; + } + + infoListAdapter.getItemsList().clear(); + infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); + restoreFocus((Integer) savedObjects.poll()); + } + + private void restoreFocus(final Integer position) { + if (position == null || position < 0) { + return; + } + + itemsList.post(() -> { + RecyclerView.ViewHolder focusedHolder = + itemsList.findViewHolderForAdapterPosition(position); + + if (focusedHolder != null) { + focusedHolder.itemView.requestFocus(); + } + }); + } + + @Override + public void onSaveInstanceState(final Bundle bundle) { + super.onSaveInstanceState(bundle); + if (useDefaultStateSaving) { + savedState = StateSaver + .tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); + } + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle bundle) { + super.onRestoreInstanceState(bundle); + if (useDefaultStateSaving) { + savedState = StateSaver.tryToRestore(bundle, this); + } + } + + @Override + public void onStop() { + focusedPosition = getFocusedPosition(); + super.onStop(); + } + + @Override + public void onStart() { + super.onStart(); + restoreFocus(focusedPosition); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + protected View getListHeader() { + return null; + } + + protected View getListFooter() { + return activity.getLayoutInflater().inflate(R.layout.pignate_footer, itemsList, false); + } + + protected RecyclerView.LayoutManager getListLayoutManager() { + return new SuperScrollLayoutManager(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(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + final boolean useGrid = isGridLayout(); + itemsList = rootView.findViewById(R.id.items_list); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + + infoListAdapter.setUseGridVariant(useGrid); + infoListAdapter.setFooter(getListFooter()); + infoListAdapter.setHeader(getListHeader()); + + itemsList.setAdapter(infoListAdapter); + } + + protected void onItemSelected(final InfoItem selectedItem) { + if (DEBUG) { + Log.d(TAG, "onItemSelected() called with: selectedItem = [" + selectedItem + "]"); + } + } + + @Override + protected void initListeners() { + super.initListeners(); + infoListAdapter.setOnStreamSelectedListener(new OnClickGesture() { + @Override + public void selected(final StreamInfoItem selectedItem) { + onStreamSelected(selectedItem); + } + + @Override + public void held(final StreamInfoItem selectedItem) { + showStreamDialog(selectedItem); + } + }); + + infoListAdapter.setOnChannelSelectedListener(new OnClickGesture() { + @Override + public void selected(final ChannelInfoItem selectedItem) { + try { + onItemSelected(selectedItem); + NavigationHelper.openChannelFragment(getFM(), + selectedItem.getServiceId(), + selectedItem.getUrl(), + selectedItem.getName()); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + } + }); + + infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture() { + @Override + public void selected(final PlaylistInfoItem selectedItem) { + try { + onItemSelected(selectedItem); + NavigationHelper.openPlaylistFragment(getFM(), + selectedItem.getServiceId(), + selectedItem.getUrl(), + selectedItem.getName()); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + } + }); + + infoListAdapter.setOnCommentsSelectedListener(new OnClickGesture() { + @Override + public void selected(final CommentsInfoItem selectedItem) { + onItemSelected(selectedItem); + } + }); + + itemsList.clearOnScrollListeners(); + itemsList.addOnScrollListener(new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(final RecyclerView recyclerView) { + onScrollToBottom(); + } + }); + } + + private void onStreamSelected(final StreamInfoItem selectedItem) { + onItemSelected(selectedItem); + NavigationHelper.openVideoDetailFragment(getFM(), + selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); + } + + protected void onScrollToBottom() { + if (hasMoreItems() && !isLoading.get()) { + loadMoreItems(); + } + } + + + protected void showStreamDialog(final StreamInfoItem item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || activity == null) { + return; + } + + if (item.getStreamType() == StreamType.AUDIO_STREAM) { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + } else { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.enqueue_on_popup, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + } + + new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), + (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + super.onCreateOptionsMenu(menu, inflater); + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + if (useAsFrontPage) { + supportActionBar.setDisplayHomeAsUpEnabled(false); + } else { + supportActionBar.setDisplayHomeAsUpEnabled(true); + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + protected abstract void loadMoreItems(); + + protected abstract boolean hasMoreItems(); + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + // animateView(itemsList, false, 400); + } + + @Override + public void hideLoading() { + super.hideLoading(); + animateView(itemsList, true, 300); + } + + @Override + public void showError(final String message, final boolean showRetryButton) { + super.showError(message, showRetryButton); + showListFooter(false); + animateView(itemsList, false, 200); + } + + @Override + public void showEmptyState() { + super.showEmptyState(); + showListFooter(false); + } + + @Override + public void showListFooter(final boolean show) { + itemsList.post(() -> { + if (infoListAdapter != null && itemsList != null) { + infoListAdapter.showFooter(show); + } + }); + } + + @Override + public void handleNextItems(final N result) { + isLoading.set(false); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(getString(R.string.list_view_mode_key))) { + updateFlags |= LIST_MODE_UPDATE_FLAG; + } + } + + protected boolean isGridLayout() { + final String listMode = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(getString(R.string.list_view_mode_key), + getString(R.string.list_view_mode_value)); + if ("auto".equals(listMode)) { + final Configuration configuration = getResources().getConfiguration(); + return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); + } else { + return "grid".equals(listMode); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListInfoFragment.java new file mode 100644 index 000000000..aaca7acbe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/BaseListInfoFragment.java @@ -0,0 +1,228 @@ +package org.schabi.newpipelegacy.fragments.list; + +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.ListInfo; +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.views.NewPipeRecyclerView; + +import java.util.Queue; + +import icepick.State; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public abstract class BaseListInfoFragment + extends BaseListFragment { + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + protected String name; + @State + protected String url; + + protected I currentInfo; + protected Page currentNextPage; + protected Disposable currentWorker; + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + setTitle(name); + showListFooter(hasMoreItems()); + } + + @Override + public void onPause() { + super.onPause(); + if (currentWorker != null) { + currentWorker.dispose(); + } + } + + @Override + public void onResume() { + super.onResume(); + // Check if it was loading when the fragment was stopped/paused, + if (wasLoading.getAndSet(false)) { + if (hasMoreItems() && infoListAdapter.getItemsList().size() > 0) { + loadMoreItems(); + } else { + doInitialLoadLogic(); + } + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (currentWorker != null) { + currentWorker.dispose(); + currentWorker = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void writeTo(final Queue objectsToSave) { + super.writeTo(objectsToSave); + objectsToSave.add(currentInfo); + objectsToSave.add(currentNextPage); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + super.readFrom(savedObjects); + currentInfo = (I) savedObjects.poll(); + currentNextPage = (Page) savedObjects.poll(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + protected void doInitialLoadLogic() { + if (DEBUG) { + Log.d(TAG, "doInitialLoadLogic() called"); + } + if (currentInfo == null) { + startLoading(false); + } else { + handleResult(currentInfo); + } + } + + /** + * Implement the logic to load the info from the network.
+ * You can use the default implementations from + * {@link org.schabi.newpipelegacy.util.ExtractorHelper}. + * + * @param forceLoad allow or disallow the result to come from the cache + * @return Rx {@link Single} containing the {@link ListInfo} + */ + protected abstract Single loadResult(boolean forceLoad); + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + showListFooter(false); + infoListAdapter.clearStreamItemList(); + + currentInfo = null; + if (currentWorker != null) { + currentWorker.dispose(); + } + currentWorker = loadResult(forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((@NonNull I result) -> { + isLoading.set(false); + currentInfo = result; + currentNextPage = result.getNextPage(); + handleResult(result); + }, (@NonNull Throwable throwable) -> onError(throwable)); + } + + /** + * Implement the logic to load more items. + *

You can use the default implementations + * from {@link org.schabi.newpipelegacy.util.ExtractorHelper}.

+ * + * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} + */ + protected abstract Single loadMoreItemsLogic(); + + protected void loadMoreItems() { + isLoading.set(true); + + if (currentWorker != null) { + currentWorker.dispose(); + } + + forbidDownwardFocusScroll(); + + currentWorker = loadMoreItemsLogic() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally(this::allowDownwardFocusScroll) + .subscribe((@NonNull ListExtractor.InfoItemsPage InfoItemsPage) -> { + isLoading.set(false); + handleNextItems(InfoItemsPage); + }, (@NonNull Throwable throwable) -> { + isLoading.set(false); + onError(throwable); + }); + } + + private void forbidDownwardFocusScroll() { + if (itemsList instanceof NewPipeRecyclerView) { + ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(false); + } + } + + private void allowDownwardFocusScroll() { + if (itemsList instanceof NewPipeRecyclerView) { + ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(true); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + currentNextPage = result.getNextPage(); + infoListAdapter.addInfoItemList(result.getItems()); + + showListFooter(hasMoreItems()); + } + + @Override + protected boolean hasMoreItems() { + return Page.isValid(currentNextPage); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void handleResult(@NonNull final I result) { + super.handleResult(result); + + name = result.getName(); + setTitle(name); + + if (infoListAdapter.getItemsList().size() == 0) { + if (result.getRelatedItems().size() > 0) { + infoListAdapter.addInfoItemList(result.getRelatedItems()); + showListFooter(hasMoreItems()); + } else { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + protected void setInitialData(final int sid, final String u, final String title) { + this.serviceId = sid; + this.url = u; + this.name = !TextUtils.isEmpty(title) ? title : ""; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/ListViewContract.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/ListViewContract.java new file mode 100644 index 000000000..1baa518bf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/ListViewContract.java @@ -0,0 +1,9 @@ +package org.schabi.newpipelegacy.fragments.list; + +import org.schabi.newpipelegacy.fragments.ViewContract; + +public interface ListViewContract extends ViewContract { + void showListFooter(boolean show); + + void handleNextItems(N result); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/channel/ChannelFragment.java new file mode 100644 index 000000000..2096600a3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/channel/ChannelFragment.java @@ -0,0 +1,604 @@ +package org.schabi.newpipelegacy.fragments.list.channel; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Log; +import android.util.TypedValue; +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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.jakewharton.rxbinding2.view.RxView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.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.exceptions.ContentNotSupportedException; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.fragments.list.BaseListInfoFragment; +import org.schabi.newpipelegacy.local.subscription.SubscriptionManager; +import org.schabi.newpipelegacy.player.playqueue.ChannelPlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ShareUtils; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.Iterator; +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.newpipelegacy.util.AnimationUtils.animateBackgroundColor; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateTextColor; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public class ChannelFragment extends BaseListInfoFragment + implements View.OnClickListener { + private static final int BUTTON_DEBOUNCE_INTERVAL = 100; + private final CompositeDisposable disposables = new CompositeDisposable(); + private Disposable subscribeButtonMonitor; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private SubscriptionManager subscriptionManager; + private View headerRootLayout; + private ImageView headerChannelBanner; + private ImageView headerAvatarView; + private TextView headerTitleView; + private ImageView headerSubChannelAvatarView; + private TextView headerSubChannelTitleView; + private TextView headerSubscribersTextView; + private Button headerSubscribeButton; + private View playlistCtrl; + private LinearLayout headerPlayAllButton; + private LinearLayout headerPopupButton; + private LinearLayout headerBackgroundButton; + private MenuItem menuRssButton; + private TextView contentNotSupportedTextView; + private TextView kaomojiTextView; + private TextView noVideosTextView; + + public static ChannelFragment getInstance(final int serviceId, final String url, + final String name) { + ChannelFragment instance = new ChannelFragment(); + instance.setInitialData(serviceId, url, name); + return instance; + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (activity != null + && useAsFrontPage + && isVisibleToUser) { + setTitle(currentInfo != null ? currentInfo.getName() : name); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + subscriptionManager = new SubscriptionManager(activity); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_channel, container, false); + } + + @Override + public void onViewCreated(final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + contentNotSupportedTextView = rootView.findViewById(R.id.error_content_not_supported); + kaomojiTextView = rootView.findViewById(R.id.channel_kaomoji); + noVideosTextView = rootView.findViewById(R.id.channel_no_videos); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) { + disposables.clear(); + } + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + protected View getListHeader() { + headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.channel_header, itemsList, false); + headerChannelBanner = headerRootLayout.findViewById(R.id.channel_banner_image); + headerAvatarView = headerRootLayout.findViewById(R.id.channel_avatar_view); + headerTitleView = headerRootLayout.findViewById(R.id.channel_title_view); + headerSubscribersTextView = headerRootLayout.findViewById(R.id.channel_subscriber_view); + headerSubscribeButton = headerRootLayout.findViewById(R.id.channel_subscribe_button); + playlistCtrl = headerRootLayout.findViewById(R.id.playlist_control); + headerSubChannelAvatarView = + headerRootLayout.findViewById(R.id.sub_channel_avatar_view); + headerSubChannelTitleView = + headerRootLayout.findViewById(R.id.sub_channel_title_view); + + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + headerSubChannelTitleView.setOnClickListener(this); + headerSubChannelAvatarView.setOnClickListener(this); + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + ActionBar supportActionBar = activity.getSupportActionBar(); + if (useAsFrontPage && supportActionBar != null) { + supportActionBar.setDisplayHomeAsUpEnabled(false); + } else { + inflater.inflate(R.menu.menu_channel, menu); + + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + menuRssButton = menu.findItem(R.id.menu_item_rss); + } + } + + private void openRssFeed() { + final ChannelInfo info = currentInfo; + if (info != null) { + Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(info.getFeedUrl())); + startActivity(intent); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_settings: + NavigationHelper.openSettings(requireContext()); + break; + case R.id.menu_item_rss: + openRssFeed(); + break; + case R.id.menu_item_openInBrowser: + if (currentInfo != null) { + ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getOriginalUrl()); + } + break; + case R.id.menu_item_share: + if (currentInfo != null) { + ShareUtils.shareUrl(requireContext(), name, currentInfo.getOriginalUrl()); + } + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Channel Subscription + //////////////////////////////////////////////////////////////////////////*/ + + private void monitorSubscription(final ChannelInfo info) { + final Consumer onError = (Throwable throwable) -> { + animateView(headerSubscribeButton, false, 100); + showSnackBarError(throwable, UserAction.SUBSCRIPTION, + NewPipe.getNameOfService(currentInfo.getServiceId()), + "Get subscription status", 0); + }; + + final Observable> observable = subscriptionManager + .subscriptionTable() + .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) + .toObservable(); + + disposables.add(observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscribeUpdateMonitor(info), onError)); + + disposables.add(observable + // Some updates are very rapid + // (for example when calling the updateSubscription(info)) + // so only update the UI for the latest emission + // ("sync" the subscribe button's state) + .debounce(100, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe((List subscriptionEntities) -> + updateSubscribeButton(!subscriptionEntities.isEmpty()), onError)); + + } + + private Function mapOnSubscribe(final SubscriptionEntity subscription, + final ChannelInfo info) { + return (@NonNull Object o) -> { + subscriptionManager.insertSubscription(subscription, info); + return o; + }; + } + + private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { + return (@NonNull Object o) -> { + subscriptionManager.deleteSubscription(subscription); + return o; + }; + } + + private void updateSubscription(final ChannelInfo info) { + if (DEBUG) { + Log.d(TAG, "updateSubscription() called with: info = [" + info + "]"); + } + final Action onComplete = () -> { + if (DEBUG) { + Log.d(TAG, "Updated subscription: " + info.getUrl()); + } + }; + + final Consumer onError = (@NonNull Throwable throwable) -> + onUnrecoverableError(throwable, + UserAction.SUBSCRIPTION, + NewPipe.getNameOfService(info.getServiceId()), + "Updating Subscription for " + info.getUrl(), + R.string.subscription_update_failed); + + disposables.add(subscriptionManager.updateChannelInfo(info) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(onComplete, onError)); + } + + private Disposable monitorSubscribeButton(final Button subscribeButton, + final Function action) { + final Consumer onNext = (@NonNull Object o) -> { + if (DEBUG) { + Log.d(TAG, "Changed subscription status to this channel!"); + } + }; + + final Consumer onError = (@NonNull Throwable throwable) -> + onUnrecoverableError(throwable, + UserAction.SUBSCRIPTION, + NewPipe.getNameOfService(currentInfo.getServiceId()), + "Subscription Change", + R.string.subscription_change_failed); + + /* Emit clicks from main thread unto io thread */ + return RxView.clicks(subscribeButton) + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks + .map(action) + .subscribe(onNext, onError); + } + + private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { + return (List subscriptionEntities) -> { + if (DEBUG) { + Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " + + "subscriptionEntities = [" + subscriptionEntities + "]"); + } + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + + if (subscriptionEntities.isEmpty()) { + if (DEBUG) { + Log.d(TAG, "No subscription to this channel!"); + } + SubscriptionEntity channel = new SubscriptionEntity(); + channel.setServiceId(info.getServiceId()); + channel.setUrl(info.getUrl()); + channel.setData(info.getName(), + info.getAvatarUrl(), + info.getDescription(), + info.getSubscriberCount()); + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, + mapOnSubscribe(channel, info)); + } else { + if (DEBUG) { + Log.d(TAG, "Found subscription to this channel!"); + } + final SubscriptionEntity subscription = subscriptionEntities.get(0); + subscribeButtonMonitor = monitorSubscribeButton(headerSubscribeButton, + mapOnUnsubscribe(subscription)); + } + }; + } + + private void updateSubscribeButton(final boolean isSubscribed) { + if (DEBUG) { + Log.d(TAG, "updateSubscribeButton() called with: " + + "isSubscribed = [" + isSubscribed + "]"); + } + + boolean isButtonVisible = headerSubscribeButton.getVisibility() == View.VISIBLE; + int backgroundDuration = isButtonVisible ? 300 : 0; + int textDuration = isButtonVisible ? 200 : 0; + + int subscribeBackground = ThemeHelper + .resolveColorFromAttr(activity, R.attr.colorPrimary); + int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color); + int subscribedBackground = ContextCompat + .getColor(activity, R.color.subscribed_background_color); + int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); + + if (!isSubscribed) { + headerSubscribeButton.setText(R.string.subscribe_button_title); + animateBackgroundColor(headerSubscribeButton, backgroundDuration, subscribedBackground, + subscribeBackground); + animateTextColor(headerSubscribeButton, textDuration, subscribedText, subscribeText); + } else { + headerSubscribeButton.setText(R.string.subscribed_button_title); + animateBackgroundColor(headerSubscribeButton, backgroundDuration, subscribeBackground, + subscribedBackground); + animateTextColor(headerSubscribeButton, textDuration, subscribeText, subscribedText); + } + + animateView(headerSubscribeButton, AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, true, 100); + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadMoreItemsLogic() { + return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad); + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onClick(final View v) { + if (isLoading.get() || currentInfo == null) { + return; + } + + switch (v.getId()) { + case R.id.sub_channel_avatar_view: + case R.id.sub_channel_title_view: + if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { + try { + NavigationHelper.openChannelFragment(getFragmentManager(), + currentInfo.getServiceId(), currentInfo.getParentChannelUrl(), + currentInfo.getParentChannelName()); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + } else if (DEBUG) { + Log.i(TAG, "Can't open parent channel because we got no channel URL"); + } + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + + IMAGE_LOADER.cancelDisplayTask(headerChannelBanner); + IMAGE_LOADER.cancelDisplayTask(headerAvatarView); + IMAGE_LOADER.cancelDisplayTask(headerSubChannelAvatarView); + animateView(headerSubscribeButton, false, 100); + } + + @Override + public void handleResult(@NonNull final ChannelInfo result) { + super.handleResult(result); + + headerRootLayout.setVisibility(View.VISIBLE); + IMAGE_LOADER.displayImage(result.getBannerUrl(), headerChannelBanner, + ImageDisplayConstants.DISPLAY_BANNER_OPTIONS); + IMAGE_LOADER.displayImage(result.getAvatarUrl(), headerAvatarView, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + IMAGE_LOADER.displayImage(result.getParentChannelAvatarUrl(), headerSubChannelAvatarView, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + + headerSubscribersTextView.setVisibility(View.VISIBLE); + if (result.getSubscriberCount() >= 0) { + headerSubscribersTextView.setText(Localization + .shortSubscriberCount(activity, result.getSubscriberCount())); + } else { + headerSubscribersTextView.setText(R.string.subscribers_count_not_available); + } + + if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { + headerSubChannelTitleView.setText(String.format( + getString(R.string.channel_created_by), + currentInfo.getParentChannelName()) + ); + headerSubChannelTitleView.setVisibility(View.VISIBLE); + headerSubChannelAvatarView.setVisibility(View.VISIBLE); + } else { + headerSubChannelTitleView.setVisibility(View.GONE); + } + + if (menuRssButton != null) { + menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); + } + + playlistCtrl.setVisibility(View.VISIBLE); + + List errors = new ArrayList<>(result.getErrors()); + if (!errors.isEmpty()) { + + // handling ContentNotSupportedException not to show the error but an appropriate string + // so that crashes won't be sent uselessly and the user will understand what happened + for (Iterator it = errors.iterator(); it.hasNext();) { + Throwable throwable = it.next(); + if (throwable instanceof ContentNotSupportedException) { + showContentNotSupported(); + it.remove(); + } + } + + if (!errors.isEmpty()) { + showSnackBarError(errors, UserAction.REQUESTED_CHANNEL, + NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + } + } + + if (disposables != null) { + disposables.clear(); + } + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor.dispose(); + } + updateSubscription(result); + monitorSubscription(result); + + headerPlayAllButton.setOnClickListener(view -> NavigationHelper + .playOnMainPlayer(activity, getPlayQueue(), false)); + headerPopupButton.setOnClickListener(view -> NavigationHelper + .playOnPopupPlayer(activity, getPlayQueue(), false)); + headerBackgroundButton.setOnClickListener(view -> NavigationHelper + .playOnBackgroundPlayer(activity, getPlayQueue(), false)); + + headerPopupButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue(), true); + return true; + }); + + headerBackgroundButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue(), true); + return true; + }); + } + + private void showContentNotSupported() { + contentNotSupportedTextView.setVisibility(View.VISIBLE); + kaomojiTextView.setText("(︶︹︺)"); + kaomojiTextView.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); + noVideosTextView.setVisibility(View.GONE); + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + final List streamItems = new ArrayList<>(); + for (InfoItem i : infoListAdapter.getItemsList()) { + if (i instanceof StreamInfoItem) { + streamItems.add((StreamInfoItem) i); + } + } + return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(), + currentInfo.getNextPage(), streamItems, index); + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), + UserAction.REQUESTED_CHANNEL, + NewPipe.getNameOfService(serviceId), + "Get next page of: " + url, + R.string.general_error); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnError + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + int errorId = exception instanceof ExtractionException + ? R.string.parsing_error : R.string.general_error; + + onUnrecoverableError(exception, UserAction.REQUESTED_CHANNEL, + NewPipe.getNameOfService(serviceId), url, errorId); + + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void setTitle(final String title) { + super.setTitle(title); + if (!useAsFrontPage) { + headerTitleView.setText(title); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/comments/CommentsFragment.java new file mode 100644 index 000000000..46785d181 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/comments/CommentsFragment.java @@ -0,0 +1,148 @@ +package org.schabi.newpipelegacy.fragments.list.comments; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.comments.CommentsInfo; +import org.schabi.newpipelegacy.fragments.list.BaseListInfoFragment; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.ExtractorHelper; + +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; + +public class CommentsFragment extends BaseListInfoFragment { + private CompositeDisposable disposables = new CompositeDisposable(); + + private boolean mIsVisibleToUser = false; + + public static CommentsFragment getInstance(final int serviceId, final String url, + final String name) { + CommentsFragment instance = new CommentsFragment(); + instance.setInitialData(serviceId, url, name); + return instance; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + mIsVisibleToUser = isVisibleToUser; + } + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_comments, container, false); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) { + disposables.clear(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadMoreItemsLogic() { + return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + } + + @Override + public void handleResult(@NonNull final CommentsInfo result) { + super.handleResult(result); + + AnimationUtils.slideUp(getView(), 120, 150, 0.06f); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_COMMENTS, + NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + } + + if (disposables != null) { + disposables.clear(); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_COMMENTS, + NewPipe.getNameOfService(serviceId), "Get next page of: " + url, + R.string.general_error); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnError + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected boolean onError(final 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; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void setTitle(final String title) { } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { } + + @Override + protected boolean isGridLayout() { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/DefaultKioskFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/DefaultKioskFragment.java new file mode 100644 index 000000000..c5f609f38 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/DefaultKioskFragment.java @@ -0,0 +1,53 @@ +package org.schabi.newpipelegacy.fragments.list.kiosk; + +import android.os.Bundle; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.kiosk.KioskList; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.KioskTranslator; +import org.schabi.newpipelegacy.util.ServiceHelper; + +public class DefaultKioskFragment extends KioskFragment { + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (serviceId < 0) { + updateSelectedDefaultKiosk(); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (serviceId != ServiceHelper.getSelectedServiceId(requireContext())) { + if (currentWorker != null) { + currentWorker.dispose(); + } + updateSelectedDefaultKiosk(); + reloadContent(); + } + } + + private void updateSelectedDefaultKiosk() { + try { + serviceId = ServiceHelper.getSelectedServiceId(requireContext()); + + final KioskList kioskList = NewPipe.getService(serviceId).getKioskList(); + kioskId = kioskList.getDefaultKioskId(); + url = kioskList.getListLinkHandlerFactoryByType(kioskId).fromId(kioskId).getUrl(); + + kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, requireContext()); + name = kioskTranslatedName; + + currentInfo = null; + currentNextPage = null; + } catch (ExtractionException e) { + onUnrecoverableError(e, UserAction.REQUESTED_KIOSK, "none", + "Loading default kiosk from selected service", 0); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/KioskFragment.java new file mode 100644 index 000000000..0845f5b41 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/kiosk/KioskFragment.java @@ -0,0 +1,190 @@ +package org.schabi.newpipelegacy.fragments.list.kiosk; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.kiosk.KioskInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipelegacy.fragments.list.BaseListInfoFragment; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.KioskTranslator; +import org.schabi.newpipelegacy.util.Localization; + +import icepick.State; +import io.reactivex.Single; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +/** + * Created by Christian Schabesberger on 23.09.17. + *

+ * Copyright (C) Christian Schabesberger 2017 + * KioskFragment.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 KioskFragment extends BaseListInfoFragment { + @State + String kioskId = ""; + String kioskTranslatedName; + @State + ContentCountry contentCountry; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + public static KioskFragment getInstance(final int serviceId) throws ExtractionException { + return getInstance(serviceId, NewPipe.getService(serviceId) + .getKioskList().getDefaultKioskId()); + } + + public static KioskFragment getInstance(final int serviceId, final String kioskId) + throws ExtractionException { + KioskFragment instance = new KioskFragment(); + StreamingService service = NewPipe.getService(serviceId); + ListLinkHandlerFactory kioskLinkHandlerFactory = service.getKioskList() + .getListLinkHandlerFactoryByType(kioskId); + instance.setInitialData(serviceId, + kioskLinkHandlerFactory.fromId(kioskId).getUrl(), kioskId); + instance.kioskId = kioskId; + return instance; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, activity); + name = kioskTranslatedName; + contentCountry = Localization.getPreferredContentCountry(requireContext()); + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (useAsFrontPage && isVisibleToUser && activity != null) { + try { + setTitle(kioskTranslatedName); + } catch (Exception e) { + onUnrecoverableError(e, UserAction.UI_ERROR, + "none", + "none", R.string.app_ui_crash); + } + } + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_kiosk, container, false); + } + + @Override + public void onResume() { + super.onResume(); + + if (!Localization.getPreferredContentCountry(requireContext()).equals(contentCountry)) { + reloadContent(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null && useAsFrontPage) { + supportActionBar.setDisplayHomeAsUpEnabled(false); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public Single loadResult(final boolean forceReload) { + contentCountry = Localization.getPreferredContentCountry(requireContext()); + return ExtractorHelper.getKioskInfo(serviceId, url, forceReload); + } + + @Override + public Single loadMoreItemsLogic() { + return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + animateView(itemsList, false, 100); + } + + @Override + public void handleResult(@NonNull final KioskInfo result) { + super.handleResult(result); + + name = kioskTranslatedName; + setTitle(kioskTranslatedName); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), + UserAction.REQUESTED_KIOSK, + NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), + UserAction.REQUESTED_PLAYLIST, NewPipe.getNameOfService(serviceId), + "Get next page of: " + url, 0); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/playlist/PlaylistFragment.java new file mode 100644 index 000000000..78e48d03a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/playlist/PlaylistFragment.java @@ -0,0 +1,482 @@ +package org.schabi.newpipelegacy.fragments.list.playlist; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +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.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.fragments.list.BaseListInfoFragment; +import org.schabi.newpipelegacy.info_list.InfoItemDialog; +import org.schabi.newpipelegacy.local.playlist.RemotePlaylistManager; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlaylistPlayQueue; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ShareUtils; +import org.schabi.newpipelegacy.util.StreamDialogEntry; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.Disposables; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public class PlaylistFragment extends BaseListInfoFragment { + private CompositeDisposable disposables; + private Subscription bookmarkReactor; + private AtomicBoolean isBookmarkButtonReady; + + private RemotePlaylistManager remotePlaylistManager; + private PlaylistRemoteEntity playlistEntity; + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private View headerRootLayout; + private TextView headerTitleView; + private View headerUploaderLayout; + private TextView headerUploaderName; + private ImageView headerUploaderAvatar; + private TextView headerStreamCount; + private View playlistCtrl; + + private View headerPlayAllButton; + private View headerPopupButton; + private View headerBackgroundButton; + + private MenuItem playlistBookmarkButton; + + public static PlaylistFragment getInstance(final int serviceId, final String url, + final String name) { + PlaylistFragment instance = new PlaylistFragment(); + instance.setInitialData(serviceId, url, name); + return instance; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + disposables = new CompositeDisposable(); + isBookmarkButtonReady = new AtomicBoolean(false); + remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase + .getInstance(requireContext())); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_playlist, container, false); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + protected View getListHeader() { + headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.playlist_header, itemsList, false); + headerTitleView = headerRootLayout.findViewById(R.id.playlist_title_view); + headerUploaderLayout = headerRootLayout.findViewById(R.id.uploader_layout); + headerUploaderName = headerRootLayout.findViewById(R.id.uploader_name); + headerUploaderAvatar = headerRootLayout.findViewById(R.id.uploader_avatar_view); + headerStreamCount = headerRootLayout.findViewById(R.id.playlist_stream_count); + playlistCtrl = headerRootLayout.findViewById(R.id.playlist_control); + + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + + + return headerRootLayout; + } + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + infoListAdapter.setUseMiniVariant(true); + } + + private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { + return getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(infoItem), 0)); + } + + @Override + protected void showStreamDialog(final StreamInfoItem item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || activity == null) { + return; + } + + if (item.getStreamType() == StreamType.AUDIO_STREAM) { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + } else { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.enqueue_on_popup, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + + StreamDialogEntry.start_here_on_popup.setCustomAction((fragment, infoItem) -> + NavigationHelper.playOnPopupPlayer(context, + getPlayQueueStartingAt(infoItem), true)); + } + + StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) -> + NavigationHelper.playOnBackgroundPlayer(context, + getPlayQueueStartingAt(infoItem), true)); + + new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), + (dialog, which) -> StreamDialogEntry.clickOn(which, this, item)).show(); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.menu_playlist, menu); + + playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); + updateBookmarkButtons(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (isBookmarkButtonReady != null) { + isBookmarkButtonReady.set(false); + } + + if (disposables != null) { + disposables.clear(); + } + if (bookmarkReactor != null) { + bookmarkReactor.cancel(); + } + + bookmarkReactor = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (disposables != null) { + disposables.dispose(); + } + + disposables = null; + remotePlaylistManager = null; + playlistEntity = null; + isBookmarkButtonReady = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadMoreItemsLogic() { + return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_settings: + NavigationHelper.openSettings(requireContext()); + break; + case R.id.menu_item_openInBrowser: + ShareUtils.openUrlInBrowser(requireContext(), url); + break; + case R.id.menu_item_share: + ShareUtils.shareUrl(requireContext(), name, url); + break; + case R.id.menu_item_bookmark: + onBookmarkClicked(); + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void showLoading() { + super.showLoading(); + animateView(headerRootLayout, false, 200); + animateView(itemsList, false, 100); + + IMAGE_LOADER.cancelDisplayTask(headerUploaderAvatar); + animateView(headerUploaderLayout, false, 200); + } + + @Override + public void handleResult(@NonNull final PlaylistInfo result) { + super.handleResult(result); + + animateView(headerRootLayout, true, 100); + animateView(headerUploaderLayout, true, 300); + headerUploaderLayout.setOnClickListener(null); + // If we have an uploader put them into the UI + if (!TextUtils.isEmpty(result.getUploaderName())) { + headerUploaderName.setText(result.getUploaderName()); + if (!TextUtils.isEmpty(result.getUploaderUrl())) { + headerUploaderLayout.setOnClickListener(v -> { + try { + NavigationHelper.openChannelFragment(getFragmentManager(), + result.getServiceId(), + result.getUploaderUrl(), + result.getUploaderName()); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) getActivity(), e); + } + }); + } + } else { // Otherwise say we have no uploader + headerUploaderName.setText(R.string.playlist_no_uploader); + } + + playlistCtrl.setVisibility(View.VISIBLE); + + IMAGE_LOADER.displayImage(result.getUploaderAvatarUrl(), headerUploaderAvatar, + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + headerStreamCount.setText(Localization + .localizeStreamCount(getContext(), result.getStreamCount())); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_PLAYLIST, + NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + } + + remotePlaylistManager.getPlaylist(result) + .flatMap(lists -> getUpdateProcessor(lists, result), (lists, id) -> lists) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistBookmarkSubscriber()); + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); + + headerPopupButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue(), true); + return true; + }); + + headerBackgroundButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue(), true); + return true; + }); + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + final List infoItems = new ArrayList<>(); + for (InfoItem i : infoListAdapter.getItemsList()) { + if (i instanceof StreamInfoItem) { + infoItems.add((StreamInfoItem) i); + } + } + return new PlaylistPlayQueue( + currentInfo.getServiceId(), + currentInfo.getUrl(), + currentInfo.getNextPage(), + infoItems, + index + ); + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_PLAYLIST, + NewPipe.getNameOfService(serviceId), "Get next page of: " + url, 0); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnError + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + int errorId = exception instanceof ExtractionException + ? R.string.parsing_error : R.string.general_error; + onUnrecoverableError(exception, UserAction.REQUESTED_PLAYLIST, + NewPipe.getNameOfService(serviceId), url, errorId); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private Flowable getUpdateProcessor( + @NonNull final List playlists, + @NonNull final PlaylistInfo result) { + final Flowable noItemToUpdate = Flowable.just(/*noItemToUpdate=*/-1); + if (playlists.isEmpty()) { + return noItemToUpdate; + } + + final PlaylistRemoteEntity playlistRemoteEntity = playlists.get(0); + if (playlistRemoteEntity.isIdenticalTo(result)) { + return noItemToUpdate; + } + + return remotePlaylistManager.onUpdate(playlists.get(0).getUid(), result).toFlowable(); + } + + private Subscriber> getPlaylistBookmarkSubscriber() { + return new Subscriber>() { + @Override + public void onSubscribe(final Subscription s) { + if (bookmarkReactor != null) { + bookmarkReactor.cancel(); + } + bookmarkReactor = s; + bookmarkReactor.request(1); + } + + @Override + public void onNext(final List playlist) { + playlistEntity = playlist.isEmpty() ? null : playlist.get(0); + + updateBookmarkButtons(); + isBookmarkButtonReady.set(true); + + if (bookmarkReactor != null) { + bookmarkReactor.request(1); + } + } + + @Override + public void onError(final Throwable t) { + PlaylistFragment.this.onError(t); + } + + @Override + public void onComplete() { } + }; + } + + @Override + public void setTitle(final String title) { + super.setTitle(title); + headerTitleView.setText(title); + } + + private void onBookmarkClicked() { + if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() + || remotePlaylistManager == null) { + return; + } + + final Disposable action; + + if (currentInfo != null && playlistEntity == null) { + action = remotePlaylistManager.onBookmark(currentInfo) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> { /* Do nothing */ }, this::onError); + } else if (playlistEntity != null) { + action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally(() -> playlistEntity = null) + .subscribe(ignored -> { /* Do nothing */ }, this::onError); + } else { + action = Disposables.empty(); + } + + disposables.add(action); + } + + private void updateBookmarkButtons() { + if (playlistBookmarkButton == null || activity == null) { + return; + } + + final int iconAttr = playlistEntity == null + ? R.attr.ic_playlist_add : R.attr.ic_playlist_check; + + final int titleRes = playlistEntity == null + ? R.string.bookmark_playlist : R.string.unbookmark_playlist; + + playlistBookmarkButton.setIcon(ThemeHelper.resolveResourceIdFromAttr(activity, iconAttr)); + playlistBookmarkButton.setTitle(titleRes); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SearchFragment.java new file mode 100644 index 000000000..c2b1b4588 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SearchFragment.java @@ -0,0 +1,1094 @@ +package org.schabi.newpipelegacy.fragments.list.search; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.Editable; +import android.text.Html; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.Log; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.TooltipCompat; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.ReCaptchaActivity; +import org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.search.SearchExtractor; +import org.schabi.newpipe.extractor.search.SearchInfo; +import org.schabi.newpipelegacy.fragments.BackPressable; +import org.schabi.newpipelegacy.fragments.list.BaseListFragment; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.ExtractorHelper; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ServiceHelper; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.TimeUnit; + +import icepick.State; +import io.reactivex.Flowable; +import io.reactivex.Observable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.PublishSubject; + +import static android.text.Html.escapeHtml; +import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; +import static java.util.Arrays.asList; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public class SearchFragment extends BaseListFragment + implements BackPressable { + /*////////////////////////////////////////////////////////////////////////// + // Search + //////////////////////////////////////////////////////////////////////////*/ + + /** + * The suggestions will only be fetched from network if the query meet this threshold (>=). + * (local ones will be fetched regardless of the length) + */ + private static final int THRESHOLD_NETWORK_SUGGESTION = 1; + + /** + * How much time have to pass without emitting a item (i.e. the user stop typing) + * to fetch/show the suggestions, in milliseconds. + */ + private static final int SUGGESTIONS_DEBOUNCE = 120; //ms + private final PublishSubject suggestionPublisher = PublishSubject.create(); + + @State + int filterItemCheckedId = -1; + + @State + protected int serviceId = Constants.NO_SERVICE_ID; + + // these three represents the current search query + @State + String searchString; + + /** + * No content filter should add like contentFilter = all + * be aware of this when implementing an extractor. + */ + @State + String[] contentFilter = new String[0]; + + @State + String sortFilter; + + // these represents the last search + @State + String lastSearchedString; + + @State + String searchSuggestion; + + @State + boolean isCorrectedSearch; + + @State + boolean wasSearchFocused = false; + + private Map menuItemToFilterName; + private StreamingService service; + private Page nextPage; + private String contentCountry; + private boolean isSuggestionsEnabled = true; + + private Disposable searchDisposable; + private Disposable suggestionDisposable; + private final CompositeDisposable disposables = new CompositeDisposable(); + + private SuggestionListAdapter suggestionListAdapter; + private HistoryRecordManager historyRecordManager; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private View searchToolbarContainer; + private EditText searchEditText; + private View searchClear; + + private TextView correctSuggestion; + + private View suggestionsPanel; + private RecyclerView suggestionsRecyclerView; + + /*////////////////////////////////////////////////////////////////////////*/ + + private TextWatcher textWatcher; + + public static SearchFragment getInstance(final int serviceId, final String searchString) { + SearchFragment searchFragment = new SearchFragment(); + searchFragment.setQuery(serviceId, searchString, new String[0], ""); + + if (!TextUtils.isEmpty(searchString)) { + searchFragment.setSearchOnResume(); + } + + return searchFragment; + } + + /** + * Set wasLoading to true so when the fragment onResume is called, the initial search is done. + */ + private void setSearchOnResume() { + wasLoading.set(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + + suggestionListAdapter = new SuggestionListAdapter(activity); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + boolean isSearchHistoryEnabled = preferences + .getBoolean(getString(R.string.enable_search_history_key), true); + suggestionListAdapter.setShowSuggestionHistory(isSearchHistoryEnabled); + + historyRecordManager = new HistoryRecordManager(context); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + isSuggestionsEnabled = preferences + .getBoolean(getString(R.string.show_search_suggestions_key), true); + contentCountry = preferences.getString(getString(R.string.content_country_key), + getString(R.string.default_localization_key)); + } + + @Override + public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_search, container, false); + } + + @Override + public void onViewCreated(final View rootView, final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + showSearchOnStart(); + initSearchListeners(); + } + + @Override + public void onPause() { + super.onPause(); + + wasSearchFocused = searchEditText.hasFocus(); + + if (searchDisposable != null) { + searchDisposable.dispose(); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + if (disposables != null) { + disposables.clear(); + } + hideKeyboardSearch(); + } + + @Override + public void onResume() { + if (DEBUG) { + Log.d(TAG, "onResume() called"); + } + super.onResume(); + + try { + service = NewPipe.getService(serviceId); + } catch (Exception e) { + ErrorActivity.reportError(getActivity(), e, getActivity().getClass(), + getActivity().findViewById(android.R.id.content), + ErrorActivity.ErrorInfo.make(UserAction.UI_ERROR, + "", + "", R.string.general_error)); + } + + if (!TextUtils.isEmpty(searchString)) { + if (wasLoading.getAndSet(false)) { + search(searchString, contentFilter, sortFilter); + } else if (infoListAdapter.getItemsList().size() == 0) { + if (savedState == null) { + search(searchString, contentFilter, sortFilter); + } else if (!isLoading.get() && !wasSearchFocused) { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + } + } + } + + handleSearchSuggestion(); + + if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { + initSuggestionObserver(); + } + + if (TextUtils.isEmpty(searchString) || wasSearchFocused) { + showKeyboardSearch(); + showSuggestionsPanel(); + } else { + hideKeyboardSearch(); + hideSuggestionsPanel(); + } + wasSearchFocused = false; + } + + @Override + public void onDestroyView() { + if (DEBUG) { + Log.d(TAG, "onDestroyView() called"); + } + unsetSearchListeners(); + super.onDestroyView(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + if (disposables != null) { + disposables.clear(); + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + switch (requestCode) { + case ReCaptchaActivity.RECAPTCHA_REQUEST: + if (resultCode == Activity.RESULT_OK + && !TextUtils.isEmpty(searchString)) { + search(searchString, contentFilter, sortFilter); + } else { + Log.e(TAG, "ReCaptcha failed"); + } + break; + + default: + Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + suggestionsPanel = rootView.findViewById(R.id.suggestions_panel); + suggestionsRecyclerView = rootView.findViewById(R.id.suggestions_list); + suggestionsRecyclerView.setAdapter(suggestionListAdapter); + new ItemTouchHelper(new ItemTouchHelper.Callback() { + @Override + public int getMovementFlags(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder) { + return getSuggestionMovementFlags(recyclerView, viewHolder); + } + + @Override + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder, + @NonNull final RecyclerView.ViewHolder viewHolder1) { + return false; + } + + @Override + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int i) { + onSuggestionItemSwiped(viewHolder, i); + } + }).attachToRecyclerView(suggestionsRecyclerView); + + searchToolbarContainer = activity.findViewById(R.id.toolbar_search_container); + searchEditText = searchToolbarContainer.findViewById(R.id.toolbar_search_edit_text); + searchClear = searchToolbarContainer.findViewById(R.id.toolbar_search_clear); + + correctSuggestion = rootView.findViewById(R.id.correct_suggestion); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void writeTo(final Queue objectsToSave) { + super.writeTo(objectsToSave); + objectsToSave.add(nextPage); + } + + @Override + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + super.readFrom(savedObjects); + nextPage = (Page) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(final Bundle bundle) { + searchString = searchEditText != null + ? searchEditText.getText().toString() + : searchString; + super.onSaveInstanceState(bundle); + } + + /*////////////////////////////////////////////////////////////////////////// + // Init's + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void reloadContent() { + if (!TextUtils.isEmpty(searchString) + || (searchEditText != null && !TextUtils.isEmpty(searchEditText.getText()))) { + search(!TextUtils.isEmpty(searchString) + ? searchString + : searchEditText.getText().toString(), this.contentFilter, ""); + } else { + if (searchEditText != null) { + searchEditText.setText(""); + showKeyboardSearch(); + } + animateView(errorPanelRoot, false, 200); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(false); + supportActionBar.setDisplayHomeAsUpEnabled(true); + } + + menuItemToFilterName = new HashMap<>(); + + int itemId = 0; + boolean isFirstItem = true; + final Context c = getContext(); + for (String filter : service.getSearchQHFactory().getAvailableContentFilter()) { + if (filter.equals("music_songs")) { + MenuItem musicItem = menu.add(2, + itemId++, + 0, + "YouTube Music"); + musicItem.setEnabled(false); + } + menuItemToFilterName.put(itemId, filter); + MenuItem item = menu.add(1, + itemId++, + 0, + ServiceHelper.getTranslatedFilterString(filter, c)); + if (isFirstItem) { + item.setChecked(true); + isFirstItem = false; + } + } + menu.setGroupCheckable(1, true, true); + + restoreFilterChecked(menu, filterItemCheckedId); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + List cf = new ArrayList<>(1); + cf.add(menuItemToFilterName.get(item.getItemId())); + changeContentFilter(item, cf); + + return true; + } + + private void restoreFilterChecked(final Menu menu, final int itemId) { + if (itemId != -1) { + MenuItem item = menu.findItem(itemId); + if (item == null) { + return; + } + + item.setChecked(true); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Search + //////////////////////////////////////////////////////////////////////////*/ + + private void showSearchOnStart() { + if (DEBUG) { + Log.d(TAG, "showSearchOnStart() called, searchQuery → " + + searchString + + ", lastSearchedQuery → " + + lastSearchedString); + } + searchEditText.setText(searchString); + + if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) { + searchToolbarContainer.setTranslationX(100); + searchToolbarContainer.setAlpha(0f); + searchToolbarContainer.setVisibility(View.VISIBLE); + searchToolbarContainer.animate() + .translationX(0) + .alpha(1f) + .setDuration(200) + .setInterpolator(new DecelerateInterpolator()).start(); + } else { + searchToolbarContainer.setTranslationX(0); + searchToolbarContainer.setAlpha(1f); + searchToolbarContainer.setVisibility(View.VISIBLE); + } + } + + private void initSearchListeners() { + if (DEBUG) { + Log.d(TAG, "initSearchListeners() called"); + } + searchClear.setOnClickListener(v -> { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + if (TextUtils.isEmpty(searchEditText.getText())) { + NavigationHelper.gotoMainFragment(getFragmentManager()); + return; + } + + correctSuggestion.setVisibility(View.GONE); + + searchEditText.setText(""); + suggestionListAdapter.setItems(new ArrayList<>()); + showKeyboardSearch(); + }); + + TooltipCompat.setTooltipText(searchClear, getString(R.string.clear)); + + searchEditText.setOnClickListener(v -> { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + if (isSuggestionsEnabled && errorPanelRoot.getVisibility() != View.VISIBLE) { + showSuggestionsPanel(); + } + if (AndroidTvUtils.isTv(getContext())) { + showKeyboardSearch(); + } + }); + + searchEditText.setOnFocusChangeListener((View v, boolean hasFocus) -> { + if (DEBUG) { + Log.d(TAG, "onFocusChange() called with: " + + "v = [" + v + "], hasFocus = [" + hasFocus + "]"); + } + if (isSuggestionsEnabled && hasFocus + && errorPanelRoot.getVisibility() != View.VISIBLE) { + showSuggestionsPanel(); + } + }); + + suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { + @Override + public void onSuggestionItemSelected(final SuggestionItem item) { + search(item.query, new String[0], ""); + searchEditText.setText(item.query); + } + + @Override + public void onSuggestionItemInserted(final SuggestionItem item) { + searchEditText.setText(item.query); + searchEditText.setSelection(searchEditText.getText().length()); + } + + @Override + public void onSuggestionItemLongClick(final SuggestionItem item) { + if (item.fromHistory) { + showDeleteSuggestionDialog(item); + } + } + }); + + if (textWatcher != null) { + searchEditText.removeTextChangedListener(textWatcher); + } + textWatcher = new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, + final int count, final int after) { + } + + @Override + public void onTextChanged(final CharSequence s, final int start, + final int before, final int count) { + } + + @Override + public void afterTextChanged(final Editable s) { + String newText = searchEditText.getText().toString(); + suggestionPublisher.onNext(newText); + } + }; + searchEditText.addTextChangedListener(textWatcher); + searchEditText.setOnEditorActionListener( + (TextView v, int actionId, KeyEvent event) -> { + if (DEBUG) { + Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " + + "actionId = [" + actionId + "], event = [" + event + "]"); + } + if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { + hideKeyboardSearch(); + } else if (event != null + && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER + || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { + search(searchEditText.getText().toString(), new String[0], ""); + return true; + } + return false; + }); + + if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { + initSuggestionObserver(); + } + } + + private void unsetSearchListeners() { + if (DEBUG) { + Log.d(TAG, "unsetSearchListeners() called"); + } + searchClear.setOnClickListener(null); + searchClear.setOnLongClickListener(null); + searchEditText.setOnClickListener(null); + searchEditText.setOnFocusChangeListener(null); + searchEditText.setOnEditorActionListener(null); + + if (textWatcher != null) { + searchEditText.removeTextChangedListener(textWatcher); + } + textWatcher = null; + } + + private void showSuggestionsPanel() { + if (DEBUG) { + Log.d(TAG, "showSuggestionsPanel() called"); + } + animateView(suggestionsPanel, AnimationUtils.Type.LIGHT_SLIDE_AND_ALPHA, true, 200); + } + + private void hideSuggestionsPanel() { + if (DEBUG) { + Log.d(TAG, "hideSuggestionsPanel() called"); + } + animateView(suggestionsPanel, AnimationUtils.Type.LIGHT_SLIDE_AND_ALPHA, false, 200); + } + + private void showKeyboardSearch() { + if (DEBUG) { + Log.d(TAG, "showKeyboardSearch() called"); + } + if (searchEditText == null) { + return; + } + + if (searchEditText.requestFocus()) { + InputMethodManager imm = (InputMethodManager) activity.getSystemService( + Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(searchEditText, InputMethodManager.SHOW_FORCED); + } + } + + private void hideKeyboardSearch() { + if (DEBUG) { + Log.d(TAG, "hideKeyboardSearch() called"); + } + if (searchEditText == null) { + return; + } + + InputMethodManager imm = (InputMethodManager) activity + .getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(searchEditText.getWindowToken(), + InputMethodManager.RESULT_UNCHANGED_SHOWN); + + searchEditText.clearFocus(); + } + + private void showDeleteSuggestionDialog(final SuggestionItem item) { + if (activity == null || historyRecordManager == null || suggestionPublisher == null + || searchEditText == null || disposables == null) { + return; + } + final String query = item.query; + new AlertDialog.Builder(activity) + .setTitle(query) + .setMessage(R.string.delete_item_search_history) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete, (dialog, which) -> { + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(searchEditText.getText().toString()), + throwable -> showSnackBarError(throwable, + UserAction.DELETE_FROM_HISTORY, "none", + "Deleting item failed", R.string.general_error)); + disposables.add(onDelete); + }) + .show(); + } + + @Override + public boolean onBackPressed() { + if (suggestionsPanel.getVisibility() == View.VISIBLE + && infoListAdapter.getItemsList().size() > 0 + && !isLoading.get()) { + hideSuggestionsPanel(); + hideKeyboardSearch(); + searchEditText.setText(lastSearchedString); + return true; + } + return false; + } + + private void initSuggestionObserver() { + if (DEBUG) { + Log.d(TAG, "initSuggestionObserver() called"); + } + if (suggestionDisposable != null) { + suggestionDisposable.dispose(); + } + + final Observable observable = suggestionPublisher + .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) + .startWith(searchString != null + ? searchString + : "") + .filter(ss -> isSuggestionsEnabled); + + suggestionDisposable = observable + .switchMap(query -> { + final Flowable> flowable = historyRecordManager + .getRelatedSearches(query, 3, 25); + final Observable> local = flowable.toObservable() + .map(searchHistoryEntries -> { + List result = new ArrayList<>(); + for (SearchHistoryEntry entry : searchHistoryEntries) { + result.add(new SuggestionItem(true, entry.getSearch())); + } + return result; + }); + + if (query.length() < THRESHOLD_NETWORK_SUGGESTION) { + // Only pass through if the query length + // is equal or greater than THRESHOLD_NETWORK_SUGGESTION + return local.materialize(); + } + + final Observable> network = ExtractorHelper + .suggestionsFor(serviceId, query) + .toObservable() + .map(strings -> { + List result = new ArrayList<>(); + for (String entry : strings) { + result.add(new SuggestionItem(false, entry)); + } + return result; + }); + + return Observable.zip(local, network, (localResult, networkResult) -> { + List result = new ArrayList<>(); + if (localResult.size() > 0) { + result.addAll(localResult); + } + + // Remove duplicates + final Iterator iterator = networkResult.iterator(); + while (iterator.hasNext() && localResult.size() > 0) { + final SuggestionItem next = iterator.next(); + for (SuggestionItem item : localResult) { + if (item.query.equals(next.query)) { + iterator.remove(); + break; + } + } + } + + if (networkResult.size() > 0) { + result.addAll(networkResult); + } + return result; + }).materialize(); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(listNotification -> { + if (listNotification.isOnNext()) { + handleSuggestions(listNotification.getValue()); + } else if (listNotification.isOnError()) { + onSuggestionError(listNotification.getError()); + } + }); + } + + @Override + protected void doInitialLoadLogic() { + // no-op + } + + private void search(final String ss, final String[] cf, final String sf) { + if (DEBUG) { + Log.d(TAG, "search() called with: query = [" + ss + "]"); + } + if (ss.isEmpty()) { + return; + } + + try { + final StreamingService streamingService = NewPipe.getServiceByUrl(ss); + if (streamingService != null) { + showLoading(); + disposables.add(Observable + .fromCallable(() -> + NavigationHelper.getIntentByLink(activity, streamingService, ss)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(intent -> { + getFragmentManager().popBackStackImmediate(); + activity.startActivity(intent); + }, throwable -> + showError(getString(R.string.url_not_supported_toast), false))); + return; + } + } catch (Exception ignored) { + // Exception occurred, it's not a url + } + + lastSearchedString = this.searchString; + this.searchString = ss; + infoListAdapter.clearStreamItemList(); + hideSuggestionsPanel(); + hideKeyboardSearch(); + + historyRecordManager.onSearched(serviceId, ss) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + ignored -> { + }, + error -> showSnackBarError(error, UserAction.SEARCHED, + NewPipe.getNameOfService(serviceId), ss, 0) + ); + suggestionPublisher.onNext(ss); + startLoading(false); + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + if (disposables != null) { + disposables.clear(); + } + if (searchDisposable != null) { + searchDisposable.dispose(); + } + searchDisposable = ExtractorHelper.searchFor(serviceId, + searchString, + Arrays.asList(contentFilter), + sortFilter) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent((searchResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleResult, this::onError); + + } + + @Override + protected void loadMoreItems() { + if (!Page.isValid(nextPage)) { + return; + } + isLoading.set(true); + showListFooter(true); + if (searchDisposable != null) { + searchDisposable.dispose(); + } + searchDisposable = ExtractorHelper.getMoreSearchItems( + serviceId, + searchString, + asList(contentFilter), + sortFilter, + nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) + .subscribe(this::handleNextItems, this::onError); + } + + @Override + protected boolean hasMoreItems() { + // TODO: No way to tell if search has more items in the moment + return true; + } + + @Override + protected void onItemSelected(final InfoItem selectedItem) { + super.onItemSelected(selectedItem); + hideKeyboardSearch(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void changeContentFilter(final MenuItem item, final List cf) { + this.filterItemCheckedId = item.getItemId(); + item.setChecked(true); + + this.contentFilter = new String[]{cf.get(0)}; + + if (!TextUtils.isEmpty(searchString)) { + search(searchString, this.contentFilter, sortFilter); + } + } + + private void setQuery(final int sid, final String ss, final String[] cf, final String sf) { + this.serviceId = sid; + this.searchString = searchString; + this.contentFilter = cf; + this.sortFilter = sf; + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion Results + //////////////////////////////////////////////////////////////////////////*/ + + public void handleSuggestions(@NonNull final List suggestions) { + if (DEBUG) { + Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]"); + } + suggestionsRecyclerView.smoothScrollToPosition(0); + suggestionsRecyclerView.post(() -> suggestionListAdapter.setItems(suggestions)); + + if (errorPanelRoot.getVisibility() == View.VISIBLE) { + hideLoading(); + } + } + + public void onSuggestionError(final Throwable exception) { + if (DEBUG) { + Log.d(TAG, "onSuggestionError() called with: exception = [" + exception + "]"); + } + if (super.onError(exception)) { + return; + } + + int errorId = exception instanceof ParsingException + ? R.string.parsing_error + : R.string.general_error; + onUnrecoverableError(exception, UserAction.GET_SUGGESTIONS, + NewPipe.getNameOfService(serviceId), searchString, errorId); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void hideLoading() { + super.hideLoading(); + showListFooter(false); + } + + @Override + public void showError(final String message, final boolean showRetryButton) { + super.showError(message, showRetryButton); + hideSuggestionsPanel(); + hideKeyboardSearch(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Search Results + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void handleResult(@NonNull final SearchInfo result) { + final List exceptions = result.getErrors(); + if (!exceptions.isEmpty() + && !(exceptions.size() == 1 + && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { + showSnackBarError(result.getErrors(), UserAction.SEARCHED, + NewPipe.getNameOfService(serviceId), searchString, 0); + } + + searchSuggestion = result.getSearchSuggestion(); + isCorrectedSearch = result.isCorrectedSearch(); + + handleSearchSuggestion(); + + lastSearchedString = searchString; + nextPage = result.getNextPage(); + + if (infoListAdapter.getItemsList().size() == 0) { + if (!result.getRelatedItems().isEmpty()) { + infoListAdapter.addInfoItemList(result.getRelatedItems()); + } else { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + return; + } + } + + super.handleResult(result); + } + + private void handleSearchSuggestion() { + if (TextUtils.isEmpty(searchSuggestion)) { + correctSuggestion.setVisibility(View.GONE); + } else { + final String helperText = getString(isCorrectedSearch + ? R.string.search_showing_result_for + : R.string.did_you_mean); + + final String highlightedSearchSuggestion = + "" + escapeHtml(searchSuggestion) + ""; + correctSuggestion.setText( + Html.fromHtml(String.format(helperText, highlightedSearchSuggestion))); + + + correctSuggestion.setOnClickListener(v -> { + correctSuggestion.setVisibility(View.GONE); + search(searchSuggestion, contentFilter, sortFilter); + searchEditText.setText(searchSuggestion); + }); + + correctSuggestion.setOnLongClickListener(v -> { + searchEditText.setText(searchSuggestion); + searchEditText.setSelection(searchSuggestion.length()); + showKeyboardSearch(); + return true; + }); + + correctSuggestion.setVisibility(View.VISIBLE); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + showListFooter(false); + infoListAdapter.addInfoItemList(result.getItems()); + nextPage = result.getNextPage(); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.SEARCHED, + NewPipe.getNameOfService(serviceId), + "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " + + "pageIds: " + nextPage.getIds() + ", " + + "pageCookies: " + nextPage.getCookies(), 0); + } + super.handleNextItems(result); + } + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + if (exception instanceof SearchExtractor.NothingFoundException) { + infoListAdapter.clearStreamItemList(); + showEmptyState(); + } else { + int errorId = exception instanceof ParsingException + ? R.string.parsing_error + : R.string.general_error; + onUnrecoverableError(exception, UserAction.SEARCHED, + NewPipe.getNameOfService(serviceId), searchString, errorId); + } + + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion item touch helper + //////////////////////////////////////////////////////////////////////////*/ + + public int getSuggestionMovementFlags(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder viewHolder) { + final int position = viewHolder.getAdapterPosition(); + if (position == RecyclerView.NO_POSITION) { + return 0; + } + + final SuggestionItem item = suggestionListAdapter.getItem(position); + return item.fromHistory ? makeMovementFlags(0, + ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0; + } + + public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int i) { + final int position = viewHolder.getAdapterPosition(); + final String query = suggestionListAdapter.getItem(position).query; + final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> suggestionPublisher + .onNext(searchEditText.getText().toString()), + throwable -> showSnackBarError(throwable, + UserAction.DELETE_FROM_HISTORY, "none", + "Deleting item failed", R.string.general_error)); + disposables.add(onDelete); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionItem.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionItem.java new file mode 100644 index 000000000..aca66d2dc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionItem.java @@ -0,0 +1,16 @@ +package org.schabi.newpipelegacy.fragments.list.search; + +public class SuggestionItem { + final boolean fromHistory; + public final String query; + + public SuggestionItem(final boolean fromHistory, final String query) { + this.fromHistory = fromHistory; + this.query = query; + } + + @Override + public String toString() { + return "[" + fromHistory + "→" + query + "]"; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionListAdapter.java new file mode 100644 index 000000000..77fd21028 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/search/SuggestionListAdapter.java @@ -0,0 +1,137 @@ +package org.schabi.newpipelegacy.fragments.list.search; + +import android.content.Context; +import android.content.res.TypedArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.AttrRes; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; + +import java.util.ArrayList; +import java.util.List; + +public class SuggestionListAdapter + extends RecyclerView.Adapter { + private final ArrayList items = new ArrayList<>(); + private final Context context; + private OnSuggestionItemSelected listener; + private boolean showSuggestionHistory = true; + + public SuggestionListAdapter(final Context context) { + this.context = context; + } + + public void setItems(final List items) { + this.items.clear(); + if (showSuggestionHistory) { + this.items.addAll(items); + } else { + // remove history items if history is disabled + for (SuggestionItem item : items) { + if (!item.fromHistory) { + this.items.add(item); + } + } + } + notifyDataSetChanged(); + } + + public void setListener(final OnSuggestionItemSelected listener) { + this.listener = listener; + } + + public void setShowSuggestionHistory(final boolean v) { + showSuggestionHistory = v; + } + + @Override + public SuggestionItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { + return new SuggestionItemHolder(LayoutInflater.from(context) + .inflate(R.layout.item_search_suggestion, parent, false)); + } + + @Override + public void onBindViewHolder(final SuggestionItemHolder holder, final int position) { + final SuggestionItem currentItem = getItem(position); + holder.updateFrom(currentItem); + holder.queryView.setOnClickListener(v -> { + if (listener != null) { + listener.onSuggestionItemSelected(currentItem); + } + }); + holder.queryView.setOnLongClickListener(v -> { + if (listener != null) { + listener.onSuggestionItemLongClick(currentItem); + } + return true; + }); + holder.insertView.setOnClickListener(v -> { + if (listener != null) { + listener.onSuggestionItemInserted(currentItem); + } + }); + } + + SuggestionItem getItem(final int position) { + return items.get(position); + } + + @Override + public int getItemCount() { + return items.size(); + } + + public boolean isEmpty() { + return getItemCount() == 0; + } + + public interface OnSuggestionItemSelected { + void onSuggestionItemSelected(SuggestionItem item); + + void onSuggestionItemInserted(SuggestionItem item); + + void onSuggestionItemLongClick(SuggestionItem item); + } + + public static final class SuggestionItemHolder extends RecyclerView.ViewHolder { + private final TextView itemSuggestionQuery; + private final ImageView suggestionIcon; + private final View queryView; + private final View insertView; + + // Cache some ids, as they can potentially be constantly updated/recycled + private final int historyResId; + private final int searchResId; + + private SuggestionItemHolder(final View rootView) { + super(rootView); + suggestionIcon = rootView.findViewById(R.id.item_suggestion_icon); + itemSuggestionQuery = rootView.findViewById(R.id.item_suggestion_query); + + queryView = rootView.findViewById(R.id.suggestion_search); + insertView = rootView.findViewById(R.id.suggestion_insert); + + historyResId = resolveResourceIdFromAttr(rootView.getContext(), R.attr.ic_history); + searchResId = resolveResourceIdFromAttr(rootView.getContext(), R.attr.ic_search); + } + + private static int resolveResourceIdFromAttr(final Context context, + @AttrRes final int attr) { + TypedArray a = context.getTheme().obtainStyledAttributes(new int[]{attr}); + int attributeResourceId = a.getResourceId(0, 0); + a.recycle(); + return attributeResourceId; + } + + private void updateFrom(final SuggestionItem item) { + suggestionIcon.setImageResource(item.fromHistory ? historyResId : searchResId); + itemSuggestionQuery.setText(item.query); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/fragments/list/videos/RelatedVideosFragment.java b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/videos/RelatedVideosFragment.java new file mode 100644 index 000000000..d37a5a953 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/fragments/list/videos/RelatedVideosFragment.java @@ -0,0 +1,215 @@ +package org.schabi.newpipelegacy.fragments.list.videos; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Switch; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.fragments.list.BaseListInfoFragment; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.RelatedStreamInfo; + +import java.io.Serializable; + +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; + +public class RelatedVideosFragment extends BaseListInfoFragment + implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String INFO_KEY = "related_info_key"; + private CompositeDisposable disposables = new CompositeDisposable(); + private RelatedStreamInfo relatedStreamInfo; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private View headerRootLayout; + private Switch autoplaySwitch; + + public static RelatedVideosFragment getInstance(final StreamInfo info) { + RelatedVideosFragment instance = new RelatedVideosFragment(); + instance.setInitialData(info); + return instance; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_related_streams, container, false); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) { + disposables.clear(); + } + } + + protected View getListHeader() { + if (relatedStreamInfo != null && relatedStreamInfo.getRelatedItems() != null) { + headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.related_streams_header, itemsList, false); + autoplaySwitch = headerRootLayout.findViewById(R.id.autoplay_switch); + + final SharedPreferences pref = PreferenceManager + .getDefaultSharedPreferences(getContext()); + final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false); + autoplaySwitch.setChecked(autoplay); + autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) -> + PreferenceManager.getDefaultSharedPreferences(getContext()).edit() + .putBoolean(getString(R.string.auto_queue_key), b).apply()); + return headerRootLayout; + } else { + return null; + } + } + + @Override + protected Single loadMoreItemsLogic() { + return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> relatedStreamInfo); + } + + @Override + public void showLoading() { + super.showLoading(); + if (headerRootLayout != null) { + headerRootLayout.setVisibility(View.INVISIBLE); + } + } + + @Override + public void handleResult(@NonNull final RelatedStreamInfo result) { + super.handleResult(result); + + if (headerRootLayout != null) { + headerRootLayout.setVisibility(View.VISIBLE); + } + AnimationUtils.slideUp(getView(), 120, 96, 0.06f); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(result.getServiceId()), result.getUrl(), 0); + } + + if (disposables != null) { + disposables.clear(); + } + } + + @Override + public void handleNextItems(final ListExtractor.InfoItemsPage result) { + super.handleNextItems(result); + + if (!result.getErrors().isEmpty()) { + showSnackBarError(result.getErrors(), + UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(serviceId), + "Get next page of: " + url, + R.string.general_error); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnError + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + hideLoading(); + showSnackBarError(exception, UserAction.REQUESTED_STREAM, + NewPipe.getNameOfService(serviceId), url, R.string.general_error); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void setTitle(final String title) { + return; + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + return; + } + + private void setInitialData(final StreamInfo info) { + super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); + if (this.relatedStreamInfo == null) { + this.relatedStreamInfo = RelatedStreamInfo.getInfo(info); + } + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putSerializable(INFO_KEY, relatedStreamInfo); + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedState) { + super.onRestoreInstanceState(savedState); + if (savedState != null) { + Serializable serializable = savedState.getSerializable(INFO_KEY); + if (serializable instanceof RelatedStreamInfo) { + this.relatedStreamInfo = (RelatedStreamInfo) serializable; + } + } + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String s) { + SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getContext()); + boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false); + if (autoplaySwitch != null) { + autoplaySwitch.setChecked(autoplay); + } + } + + @Override + protected boolean isGridLayout() { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemBuilder.java new file mode 100644 index 000000000..802548539 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemBuilder.java @@ -0,0 +1,139 @@ +package org.schabi.newpipelegacy.info_list; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.info_list.holder.ChannelInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.ChannelMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.CommentsInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.CommentsMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.InfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.PlaylistMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.StreamInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.OnClickGesture; + +/* + * Created by Christian Schabesberger on 26.09.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * InfoItemBuilder.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 InfoItemBuilder { + private final Context context; + private final ImageLoader imageLoader = ImageLoader.getInstance(); + + private OnClickGesture onStreamSelectedListener; + private OnClickGesture onChannelSelectedListener; + private OnClickGesture onPlaylistSelectedListener; + private OnClickGesture onCommentsSelectedListener; + + public InfoItemBuilder(final Context context) { + this.context = context; + } + + public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + return buildView(parent, infoItem, historyRecordManager, false); + } + + public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem, + final HistoryRecordManager historyRecordManager, + final boolean useMiniVariant) { + InfoItemHolder holder = holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); + holder.updateFromItem(infoItem, historyRecordManager); + return holder.itemView; + } + + private InfoItemHolder holderFromInfoType(@NonNull final ViewGroup parent, + @NonNull final InfoItem.InfoType infoType, + final boolean useMiniVariant) { + switch (infoType) { + case STREAM: + return useMiniVariant ? new StreamMiniInfoItemHolder(this, parent) + : new StreamInfoItemHolder(this, parent); + case CHANNEL: + return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) + : new ChannelInfoItemHolder(this, parent); + case PLAYLIST: + return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) + : new PlaylistInfoItemHolder(this, parent); + case COMMENT: + return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent) + : new CommentsInfoItemHolder(this, parent); + default: + throw new RuntimeException("InfoType not expected = " + infoType.name()); + } + } + + public Context getContext() { + return context; + } + + public ImageLoader getImageLoader() { + return imageLoader; + } + + public OnClickGesture getOnStreamSelectedListener() { + return onStreamSelectedListener; + } + + public void setOnStreamSelectedListener(final OnClickGesture listener) { + this.onStreamSelectedListener = listener; + } + + public OnClickGesture getOnChannelSelectedListener() { + return onChannelSelectedListener; + } + + public void setOnChannelSelectedListener(final OnClickGesture listener) { + this.onChannelSelectedListener = listener; + } + + public OnClickGesture getOnPlaylistSelectedListener() { + return onPlaylistSelectedListener; + } + + public void setOnPlaylistSelectedListener(final OnClickGesture listener) { + this.onPlaylistSelectedListener = listener; + } + + public OnClickGesture getOnCommentsSelectedListener() { + return onCommentsSelectedListener; + } + + public void setOnCommentsSelectedListener( + final OnClickGesture onCommentsSelectedListener) { + this.onCommentsSelectedListener = onCommentsSelectedListener; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemDialog.java new file mode 100644 index 000000000..f168c1180 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoItemDialog.java @@ -0,0 +1,54 @@ +package org.schabi.newpipelegacy.info_list; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; + +public class InfoItemDialog { + private final AlertDialog dialog; + + public InfoItemDialog(@NonNull final Activity activity, + @NonNull final StreamInfoItem info, + @NonNull final String[] commands, + @NonNull final DialogInterface.OnClickListener actions) { + this(activity, commands, actions, info.getName(), info.getUploaderName()); + } + + public InfoItemDialog(@NonNull final Activity activity, + @NonNull final String[] commands, + @NonNull final DialogInterface.OnClickListener actions, + @NonNull final String title, + @Nullable final String additionalDetail) { + + final View bannerView = View.inflate(activity, R.layout.dialog_title, null); + bannerView.setSelected(true); + + TextView titleView = bannerView.findViewById(R.id.itemTitleView); + titleView.setText(title); + + TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); + if (additionalDetail != null) { + detailsView.setText(additionalDetail); + detailsView.setVisibility(View.VISIBLE); + } else { + detailsView.setVisibility(View.GONE); + } + + dialog = new AlertDialog.Builder(activity) + .setCustomTitle(bannerView) + .setItems(commands, actions) + .create(); + } + + public void show() { + dialog.show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoListAdapter.java new file mode 100644 index 000000000..d7d9d98cd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/InfoListAdapter.java @@ -0,0 +1,382 @@ +package org.schabi.newpipelegacy.info_list; + +import android.content.Context; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.info_list.holder.ChannelGridInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.ChannelInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.ChannelMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.CommentsInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.CommentsMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.InfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.PlaylistGridInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.PlaylistInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.PlaylistMiniInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.StreamGridInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.StreamInfoItemHolder; +import org.schabi.newpipelegacy.info_list.holder.StreamMiniInfoItemHolder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.FallbackViewHolder; +import org.schabi.newpipelegacy.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.List; + +/* + * Created by Christian Schabesberger on 01.08.16. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoListAdapter.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 InfoListAdapter extends RecyclerView.Adapter { + private static final String TAG = InfoListAdapter.class.getSimpleName(); + private static final boolean DEBUG = false; + + private static final int HEADER_TYPE = 0; + private static final int FOOTER_TYPE = 1; + + private static final int MINI_STREAM_HOLDER_TYPE = 0x100; + private static final int STREAM_HOLDER_TYPE = 0x101; + private static final int GRID_STREAM_HOLDER_TYPE = 0x102; + private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200; + private static final int CHANNEL_HOLDER_TYPE = 0x201; + private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202; + private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300; + private static final int PLAYLIST_HOLDER_TYPE = 0x301; + private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; + private static final int MINI_COMMENT_HOLDER_TYPE = 0x400; + private static final int COMMENT_HOLDER_TYPE = 0x401; + + private final InfoItemBuilder infoItemBuilder; + private final ArrayList infoItemList; + private final HistoryRecordManager recordManager; + + private boolean useMiniVariant = false; + private boolean useGridVariant = false; + private boolean showFooter = false; + private View header = null; + private View footer = null; + + public InfoListAdapter(final Context context) { + this.recordManager = new HistoryRecordManager(context); + infoItemBuilder = new InfoItemBuilder(context); + infoItemList = new ArrayList<>(); + } + + public void setOnStreamSelectedListener(final OnClickGesture listener) { + infoItemBuilder.setOnStreamSelectedListener(listener); + } + + public void setOnChannelSelectedListener(final OnClickGesture listener) { + infoItemBuilder.setOnChannelSelectedListener(listener); + } + + public void setOnPlaylistSelectedListener(final OnClickGesture listener) { + infoItemBuilder.setOnPlaylistSelectedListener(listener); + } + + public void setOnCommentsSelectedListener(final OnClickGesture listener) { + infoItemBuilder.setOnCommentsSelectedListener(listener); + } + + public void setUseMiniVariant(final boolean useMiniVariant) { + this.useMiniVariant = useMiniVariant; + } + + public void setUseGridVariant(final boolean useGridVariant) { + this.useGridVariant = useGridVariant; + } + + public void addInfoItemList(@Nullable final List data) { + if (data == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "addInfoItemList() before > infoItemList.size() = " + + infoItemList.size() + ", data.size() = " + data.size()); + } + + int offsetStart = sizeConsideringHeaderOffset(); + infoItemList.addAll(data); + + if (DEBUG) { + Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", " + + "infoItemList.size() = " + infoItemList.size() + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter); + } + notifyItemRangeInserted(offsetStart, data.size()); + + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeaderOffset(); + notifyItemMoved(offsetStart, footerNow); + + if (DEBUG) { + Log.d(TAG, "addInfoItemList() footer from " + offsetStart + + " to " + footerNow); + } + } + } + + public void setInfoItemList(final List data) { + infoItemList.clear(); + infoItemList.addAll(data); + notifyDataSetChanged(); + } + + public void addInfoItem(@Nullable final InfoItem data) { + if (data == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "addInfoItem() before > infoItemList.size() = " + + infoItemList.size() + ", thread = " + Thread.currentThread()); + } + + int positionInserted = sizeConsideringHeaderOffset(); + infoItemList.add(data); + + if (DEBUG) { + Log.d(TAG, "addInfoItem() after > position = " + positionInserted + ", " + + "infoItemList.size() = " + infoItemList.size() + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter); + } + notifyItemInserted(positionInserted); + + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeaderOffset(); + notifyItemMoved(positionInserted, footerNow); + + if (DEBUG) { + Log.d(TAG, "addInfoItem() footer from " + positionInserted + + " to " + footerNow); + } + } + } + + public void clearStreamItemList() { + if (infoItemList.isEmpty()) { + return; + } + infoItemList.clear(); + notifyDataSetChanged(); + } + + public void setHeader(final View header) { + boolean changed = header != this.header; + this.header = header; + if (changed) { + notifyDataSetChanged(); + } + } + + public void setFooter(final View view) { + this.footer = view; + } + + public void showFooter(final boolean show) { + if (DEBUG) { + Log.d(TAG, "showFooter() called with: show = [" + show + "]"); + } + if (show == showFooter) { + return; + } + + showFooter = show; + if (show) { + notifyItemInserted(sizeConsideringHeaderOffset()); + } else { + notifyItemRemoved(sizeConsideringHeaderOffset()); + } + } + + private int sizeConsideringHeaderOffset() { + int i = infoItemList.size() + (header != null ? 1 : 0); + if (DEBUG) { + Log.d(TAG, "sizeConsideringHeaderOffset() called → " + i); + } + return i; + } + + public ArrayList getItemsList() { + return infoItemList; + } + + @Override + public int getItemCount() { + int count = infoItemList.size(); + if (header != null) { + count++; + } + if (footer != null && showFooter) { + count++; + } + + if (DEBUG) { + Log.d(TAG, "getItemCount() called with: " + + "count = " + count + ", infoItemList.size() = " + infoItemList.size() + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter); + } + return count; + } + + @Override + public int getItemViewType(int position) { + if (DEBUG) { + Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); + } + + if (header != null && position == 0) { + return HEADER_TYPE; + } else if (header != null) { + position--; + } + if (footer != null && position == infoItemList.size() && showFooter) { + return FOOTER_TYPE; + } + final InfoItem item = infoItemList.get(position); + switch (item.getInfoType()) { + case STREAM: + return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant + ? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE; + case CHANNEL: + return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant + ? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE; + case PLAYLIST: + return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant + ? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE; + case COMMENT: + return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE; + default: + return -1; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int type) { + if (DEBUG) { + Log.d(TAG, "onCreateViewHolder() called with: " + + "parent = [" + parent + "], type = [" + type + "]"); + } + switch (type) { + case HEADER_TYPE: + return new HFHolder(header); + case FOOTER_TYPE: + return new HFHolder(footer); + case MINI_STREAM_HOLDER_TYPE: + return new StreamMiniInfoItemHolder(infoItemBuilder, parent); + case STREAM_HOLDER_TYPE: + return new StreamInfoItemHolder(infoItemBuilder, parent); + case GRID_STREAM_HOLDER_TYPE: + return new StreamGridInfoItemHolder(infoItemBuilder, parent); + case MINI_CHANNEL_HOLDER_TYPE: + return new ChannelMiniInfoItemHolder(infoItemBuilder, parent); + case CHANNEL_HOLDER_TYPE: + return new ChannelInfoItemHolder(infoItemBuilder, parent); + case GRID_CHANNEL_HOLDER_TYPE: + return new ChannelGridInfoItemHolder(infoItemBuilder, parent); + case MINI_PLAYLIST_HOLDER_TYPE: + return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent); + case PLAYLIST_HOLDER_TYPE: + return new PlaylistInfoItemHolder(infoItemBuilder, parent); + case GRID_PLAYLIST_HOLDER_TYPE: + return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); + case MINI_COMMENT_HOLDER_TYPE: + return new CommentsMiniInfoItemHolder(infoItemBuilder, parent); + case COMMENT_HOLDER_TYPE: + return new CommentsInfoItemHolder(infoItemBuilder, parent); + default: + return new FallbackViewHolder(new View(parent.getContext())); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { + if (DEBUG) { + Log.d(TAG, "onBindViewHolder() called with: " + + "holder = [" + holder.getClass().getSimpleName() + "], " + + "position = [" + position + "]"); + } + if (holder instanceof InfoItemHolder) { + // If header isn't null, offset the items by -1 + if (header != null) { + position--; + } + + ((InfoItemHolder) holder).updateFromItem(infoItemList.get(position), recordManager); + } else if (holder instanceof HFHolder && position == 0 && header != null) { + ((HFHolder) holder).view = header; + } else if (holder instanceof HFHolder && position == sizeConsideringHeaderOffset() + && footer != null && showFooter) { + ((HFHolder) holder).view = footer; + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position, + @NonNull final List payloads) { + if (!payloads.isEmpty() && holder instanceof InfoItemHolder) { + for (Object payload : payloads) { + if (payload instanceof StreamStateEntity) { + ((InfoItemHolder) holder).updateState(infoItemList + .get(header == null ? position : position - 1), recordManager); + } else if (payload instanceof Boolean) { + ((InfoItemHolder) holder).updateState(infoItemList + .get(header == null ? position : position - 1), recordManager); + } + } + } else { + onBindViewHolder(holder, position); + } + } + + public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { + return new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(final int position) { + final int type = getItemViewType(position); + return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; + } + }; + } + + public class HFHolder extends RecyclerView.ViewHolder { + public View view; + + HFHolder(final View v) { + super(v); + view = v; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelGridInfoItemHolder.java new file mode 100644 index 000000000..035da81ab --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelGridInfoItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; + +public class ChannelGridInfoItemHolder extends ChannelMiniInfoItemHolder { + public ChannelGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_channel_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelInfoItemHolder.java new file mode 100644 index 000000000..e3e436ec0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelInfoItemHolder.java @@ -0,0 +1,70 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.Localization; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * ChannelInfoItemHolder .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 ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { + private final TextView itemChannelDescriptionView; + + public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_channel_item, parent); + itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + super.updateFromItem(infoItem, historyRecordManager); + + if (!(infoItem instanceof ChannelInfoItem)) { + return; + } + final ChannelInfoItem item = (ChannelInfoItem) infoItem; + + itemChannelDescriptionView.setText(item.getDescription()); + } + + @Override + protected String getDetailLine(final ChannelInfoItem item) { + String details = super.getDetailLine(item); + + if (item.getStreamCount() >= 0) { + String formattedVideoAmount = Localization.localizeStreamCount(itemBuilder.getContext(), + item.getStreamCount()); + + if (!details.isEmpty()) { + details += " • " + formattedVideoAmount; + } else { + details = formattedVideoAmount; + } + } + return details; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelMiniInfoItemHolder.java new file mode 100644 index 000000000..c2e681632 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/ChannelMiniInfoItemHolder.java @@ -0,0 +1,73 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; + +import de.hdodenhof.circleimageview.CircleImageView; + +public class ChannelMiniInfoItemHolder extends InfoItemHolder { + public final CircleImageView itemThumbnailView; + public final TextView itemTitleView; + private final TextView itemAdditionalDetailView; + + ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails); + } + + public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_channel_mini_item, parent); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + if (!(infoItem instanceof ChannelInfoItem)) { + return; + } + final ChannelInfoItem item = (ChannelInfoItem) infoItem; + + itemTitleView.setText(item.getName()); + itemAdditionalDetailView.setText(getDetailLine(item)); + + itemBuilder.getImageLoader() + .displayImage(item.getThumbnailUrl(), + itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener().selected(item); + } + }); + + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener().held(item); + } + return true; + }); + } + + protected String getDetailLine(final ChannelInfoItem item) { + String details = ""; + if (item.getSubscriberCount() >= 0) { + details += Localization.shortSubscriberCount(itemBuilder.getContext(), + item.getSubscriberCount()); + } + return details; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsInfoItemHolder.java new file mode 100644 index 000000000..f54af283f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsInfoItemHolder.java @@ -0,0 +1,53 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * ChannelInfoItemHolder .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 CommentsInfoItemHolder extends CommentsMiniInfoItemHolder { + public final TextView itemTitleView; + + public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_comments_item, parent); + + itemTitleView = itemView.findViewById(R.id.itemTitleView); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + super.updateFromItem(infoItem, historyRecordManager); + + if (!(infoItem instanceof CommentsInfoItem)) { + return; + } + final CommentsInfoItem item = (CommentsInfoItem) infoItem; + + itemTitleView.setText(item.getUploaderName()); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsMiniInfoItemHolder.java new file mode 100644 index 000000000..c8e1d7e9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/CommentsMiniInfoItemHolder.java @@ -0,0 +1,224 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.CommentTextOnTouchListener; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ShareUtils; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import de.hdodenhof.circleimageview.CircleImageView; + +public class CommentsMiniInfoItemHolder extends InfoItemHolder { + private static final int COMMENT_DEFAULT_LINES = 2; + private static final int COMMENT_EXPANDED_LINES = 1000; + private static final Pattern PATTERN = Pattern.compile("(\\d+:)?(\\d+)?:(\\d+)"); + + public final CircleImageView itemThumbnailView; + private final TextView itemContentView; + private final TextView itemLikesCountView; + private final TextView itemDislikesCountView; + private final TextView itemPublishedTime; + + private String commentText; + private String streamUrl; + + private final Linkify.TransformFilter timestampLink = new Linkify.TransformFilter() { + @Override + public String transformUrl(final Matcher match, final String url) { + int timestamp = 0; + String hours = match.group(1); + String minutes = match.group(2); + String seconds = match.group(3); + if (hours != null) { + timestamp += (Integer.parseInt(hours.replace(":", "")) * 3600); + } + if (minutes != null) { + timestamp += (Integer.parseInt(minutes.replace(":", "")) * 60); + } + if (seconds != null) { + timestamp += (Integer.parseInt(seconds)); + } + return streamUrl + url.replace(match.group(0), "#timestamp=" + timestamp); + } + }; + + CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + 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(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_comments_mini_item, parent); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + if (!(infoItem instanceof CommentsInfoItem)) { + return; + } + final CommentsInfoItem item = (CommentsInfoItem) infoItem; + + itemBuilder.getImageLoader() + .displayImage(item.getUploaderAvatarUrl(), + itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); + + streamUrl = item.getUrl(); + + itemContentView.setLines(COMMENT_DEFAULT_LINES); + commentText = item.getCommentText(); + itemContentView.setText(commentText); + itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); + + if (itemContentView.getLineCount() == 0) { + itemContentView.post(this::ellipsize); + } else { + ellipsize(); + } + + if (item.getLikeCount() >= 0) { + itemLikesCountView.setText(String.valueOf(item.getLikeCount())); + } else { + itemLikesCountView.setText("-"); + } + + if (item.getUploadDate() != null) { + itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate().date())); + } else { + itemPublishedTime.setText(item.getTextualUploadDate()); + } + + itemView.setOnClickListener(view -> { + toggleEllipsize(); + if (itemBuilder.getOnCommentsSelectedListener() != null) { + itemBuilder.getOnCommentsSelectedListener().selected(item); + } + }); + + + itemView.setOnLongClickListener(view -> { + if (AndroidTvUtils.isTv(itemBuilder.getContext())) { + openCommentAuthor(item); + } else { + ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText); + } + return true; + }); + } + + private void openCommentAuthor(final CommentsInfoItem item) { + if (TextUtils.isEmpty(item.getUploaderUrl())) { + return; + } + try { + final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext(); + NavigationHelper.openChannelFragment( + activity.getSupportFragmentManager(), + item.getServiceId(), + item.getUploaderUrl(), + item.getUploaderName()); + } catch (Exception e) { + ErrorActivity.reportUiError((AppCompatActivity) itemBuilder.getContext(), e); + } + } + + private void allowLinkFocus() { + itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void denyLinkFocus() { + itemContentView.setMovementMethod(null); + } + + private boolean shouldFocusLinks() { + if (itemView.isInTouchMode()) { + return false; + } + + URLSpan[] urls = itemContentView.getUrls(); + + return urls != null && urls.length != 0; + } + + private void determineLinkFocus() { + if (shouldFocusLinks()) { + allowLinkFocus(); + } else { + denyLinkFocus(); + } + } + + private void ellipsize() { + boolean hasEllipsis = false; + + if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { + int endOfLastLine = itemContentView.getLayout().getLineEnd(COMMENT_DEFAULT_LINES - 1); + int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); + if (end == -1) { + end = Math.max(endOfLastLine - 2, 0); + } + String newVal = itemContentView.getText().subSequence(0, end) + " …"; + itemContentView.setText(newVal); + hasEllipsis = true; + } + + linkify(); + + if (hasEllipsis) { + denyLinkFocus(); + } else { + determineLinkFocus(); + } + } + + private void toggleEllipsize() { + if (itemContentView.getText().toString().equals(commentText)) { + if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { + ellipsize(); + } + } else { + expand(); + } + } + + private void expand() { + itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); + itemContentView.setText(commentText); + linkify(); + determineLinkFocus(); + } + + private void linkify() { + Linkify.addLinks(itemContentView, Linkify.WEB_URLS); + Linkify.addLinks(itemContentView, PATTERN, null, null, timestampLink); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/InfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/InfoItemHolder.java new file mode 100644 index 000000000..dd1433c49 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/InfoItemHolder.java @@ -0,0 +1,46 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoItemHolder.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 abstract class InfoItemHolder extends RecyclerView.ViewHolder { + protected final InfoItemBuilder itemBuilder; + + public InfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(LayoutInflater.from(infoItemBuilder.getContext()).inflate(layoutId, parent, false)); + this.itemBuilder = infoItemBuilder; + } + + public abstract void updateFromItem(InfoItem infoItem, + HistoryRecordManager historyRecordManager); + + public void updateState(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistGridInfoItemHolder.java new file mode 100644 index 000000000..17333944d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistGridInfoItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; + +public class PlaylistGridInfoItemHolder extends PlaylistMiniInfoItemHolder { + public PlaylistGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistInfoItemHolder.java new file mode 100644 index 000000000..bc02e86fc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistInfoItemHolder.java @@ -0,0 +1,12 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; + +public class PlaylistInfoItemHolder extends PlaylistMiniInfoItemHolder { + public PlaylistInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistMiniInfoItemHolder.java new file mode 100644 index 000000000..5f3c2d38c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/PlaylistMiniInfoItemHolder.java @@ -0,0 +1,67 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; + +public class PlaylistMiniInfoItemHolder extends InfoItemHolder { + public final ImageView itemThumbnailView; + private final TextView itemStreamCountView; + public final TextView itemTitleView; + public final TextView itemUploaderView; + + public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + } + + public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + if (!(infoItem instanceof PlaylistInfoItem)) { + return; + } + final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; + + itemTitleView.setText(item.getName()); + itemStreamCountView.setText(Localization + .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); + itemUploaderView.setText(item.getUploaderName()); + + itemBuilder.getImageLoader() + .displayImage(item.getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener().held(item); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamGridInfoItemHolder.java new file mode 100644 index 000000000..400aef9c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamGridInfoItemHolder.java @@ -0,0 +1,12 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; + +public class StreamGridInfoItemHolder extends StreamInfoItemHolder { + public StreamGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamInfoItemHolder.java new file mode 100644 index 000000000..56bb985df --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamInfoItemHolder.java @@ -0,0 +1,110 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.Localization; + +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + +/* + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * StreamInfoItemHolder.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 StreamInfoItemHolder extends StreamMiniInfoItemHolder { + public final TextView itemAdditionalDetails; + + public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_stream_item, parent); + } + + public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + super.updateFromItem(infoItem, historyRecordManager); + + if (!(infoItem instanceof StreamInfoItem)) { + return; + } + final StreamInfoItem item = (StreamInfoItem) infoItem; + + itemAdditionalDetails.setText(getStreamInfoDetailLine(item)); + } + + private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { + String viewsAndDate = ""; + if (infoItem.getViewCount() >= 0) { + if (infoItem.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { + viewsAndDate = Localization + .listeningCount(itemBuilder.getContext(), infoItem.getViewCount()); + } else if (infoItem.getStreamType().equals(StreamType.LIVE_STREAM)) { + viewsAndDate = Localization + .shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()); + } else { + viewsAndDate = Localization + .shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); + } + } + + final String uploadDate = getFormattedRelativeUploadDate(infoItem); + if (!TextUtils.isEmpty(uploadDate)) { + if (viewsAndDate.isEmpty()) { + return uploadDate; + } + + return Localization.concatenateStrings(viewsAndDate, uploadDate); + } + + return viewsAndDate; + } + + private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) { + if (infoItem.getUploadDate() != null) { + String formattedRelativeTime = Localization + .relativeTime(infoItem.getUploadDate().date()); + + if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext()) + .getBoolean(itemBuilder.getContext() + .getString(R.string.show_original_time_ago_key), false)) { + formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")"; + } + return formattedRelativeTime; + } else { + return infoItem.getTextualUploadDate(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamMiniInfoItemHolder.java new file mode 100644 index 000000000..6159c1710 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/info_list/holder/StreamMiniInfoItemHolder.java @@ -0,0 +1,147 @@ +package org.schabi.newpipelegacy.info_list.holder; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.info_list.InfoItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.views.AnimatedProgressBar; + +import java.util.concurrent.TimeUnit; + +public class StreamMiniInfoItemHolder extends InfoItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemUploaderView; + public final TextView itemDurationView; + private final AnimatedProgressBar itemProgressView; + + StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemProgressView = itemView.findViewById(R.id.itemProgressView); + } + + public StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_stream_mini_item, parent); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + if (!(infoItem instanceof StreamInfoItem)) { + return; + } + final StreamInfoItem item = (StreamInfoItem) infoItem; + + itemVideoTitleView.setText(item.getName()); + itemUploaderView.setText(item.getUploaderName()); + + if (item.getDuration() > 0) { + itemDurationView.setText(Localization.getDurationString(item.getDuration())); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + + StreamStateEntity state2 = historyRecordManager.loadStreamState(infoItem) + .blockingGet()[0]; + if (state2 != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.getDuration()); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state2.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } + } else if (item.getStreamType() == StreamType.LIVE_STREAM) { + itemDurationView.setText(R.string.duration_live); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.live_duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + itemProgressView.setVisibility(View.GONE); + } else { + itemDurationView.setVisibility(View.GONE); + itemProgressView.setVisibility(View.GONE); + } + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.getImageLoader() + .displayImage(item.getThumbnailUrl(), + itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().selected(item); + } + }); + + switch (item.getStreamType()) { + case AUDIO_STREAM: + case VIDEO_STREAM: + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + enableLongClick(item); + break; + case FILE: + case NONE: + default: + disableLongClick(); + break; + } + } + + @Override + public void updateState(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + final StreamInfoItem item = (StreamInfoItem) infoItem; + + StreamStateEntity state = historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; + if (state != null && item.getDuration() > 0 + && item.getStreamType() != StreamType.LIVE_STREAM) { + itemProgressView.setMax((int) item.getDuration()); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } + + private void enableLongClick(final StreamInfoItem item) { + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener().held(item); + } + return true; + }); + } + + private void disableLongClick() { + itemView.setLongClickable(false); + itemView.setOnLongClickListener(null); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipelegacy/local/BaseLocalListFragment.java new file mode 100644 index 000000000..76fe6ae54 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/BaseLocalListFragment.java @@ -0,0 +1,271 @@ +package org.schabi.newpipelegacy.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.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; + +import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.fragments.BaseStateFragment; +import org.schabi.newpipelegacy.fragments.list.ListViewContract; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +/** + * This fragment is design to be used with persistent data such as + * {@link org.schabi.newpipelegacy.database.LocalItem}, and does not cache the data contained + * in the list adapter to avoid extra writes when the it exits or re-enters its lifecycle. + *

+ * This fragment destroys its adapter and views when {@link Fragment#onDestroyView()} is + * called and is memory efficient when in backstack. + *

+ * + * @param List of {@link org.schabi.newpipelegacy.database.LocalItem}s + * @param {@link Void} + */ +public abstract class BaseLocalListFragment extends BaseStateFragment + implements ListViewContract, SharedPreferences.OnSharedPreferenceChangeListener { + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private static final int LIST_MODE_UPDATE_FLAG = 0x32; + private View headerRootView; + private View footerRootView; + protected LocalItemListAdapter itemListAdapter; + protected RecyclerView itemsList; + private int updateFlags = 0; + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Creation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final 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.setUseGridVariant(useGrid); + itemListAdapter.notifyDataSetChanged(); + } + updateFlags = 0; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - View + //////////////////////////////////////////////////////////////////////////*/ + + protected View getListHeader() { + return null; + } + + protected View getListFooter() { + 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); + } + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + itemListAdapter = new LocalItemListAdapter(activity); + + final boolean useGrid = isGridLayout(); + itemsList = rootView.findViewById(R.id.items_list); + itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + + itemListAdapter.setUseGridVariant(useGrid); + headerRootView = getListHeader(); + itemListAdapter.setHeader(headerRootView); + footerRootView = getListFooter(); + itemListAdapter.setFooter(footerRootView); + + itemsList.setAdapter(itemListAdapter); + } + + @Override + protected void initListeners() { + super.initListeners(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + + final ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar == null) { + return; + } + + supportActionBar.setDisplayShowTitleEnabled(true); + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Destruction + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onDestroyView() { + super.onDestroyView(); + itemsList = null; + itemListAdapter = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + resetFragment(); + } + + @Override + public void showLoading() { + super.showLoading(); + if (itemsList != null) { + animateView(itemsList, false, 200); + } + if (headerRootView != null) { + animateView(headerRootView, false, 200); + } + } + + @Override + public void hideLoading() { + super.hideLoading(); + if (itemsList != null) { + animateView(itemsList, true, 200); + } + if (headerRootView != null) { + animateView(headerRootView, true, 200); + } + } + + @Override + public void showError(final String message, final boolean showRetryButton) { + super.showError(message, showRetryButton); + showListFooter(false); + + if (itemsList != null) { + animateView(itemsList, false, 200); + } + if (headerRootView != null) { + animateView(headerRootView, false, 200); + } + } + + @Override + public void showEmptyState() { + super.showEmptyState(); + showListFooter(false); + } + + @Override + public void showListFooter(final boolean show) { + if (itemsList == null) { + return; + } + itemsList.post(() -> { + if (itemListAdapter != null) { + itemListAdapter.showFooter(show); + } + }); + } + + @Override + public void handleNextItems(final N result) { + isLoading.set(false); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + protected void resetFragment() { + if (itemListAdapter != null) { + itemListAdapter.clearStreamItemList(); + } + } + + @Override + protected boolean onError(final Throwable exception) { + resetFragment(); + return super.onError(exception); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(getString(R.string.list_view_mode_key))) { + updateFlags |= LIST_MODE_UPDATE_FLAG; + } + } + + protected boolean isGridLayout() { + final String listMode = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(getString(R.string.list_view_mode_key), + getString(R.string.list_view_mode_value)); + if ("auto".equals(listMode)) { + final Configuration configuration = getResources().getConfiguration(); + return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); + } else { + return "grid".equals(listMode); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/HeaderFooterHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/HeaderFooterHolder.java new file mode 100644 index 000000000..d6c3c918a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/HeaderFooterHolder.java @@ -0,0 +1,14 @@ +package org.schabi.newpipelegacy.local; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +public class HeaderFooterHolder extends RecyclerView.ViewHolder { + public View view; + + public HeaderFooterHolder(final View v) { + super(v); + view = v; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemBuilder.java b/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemBuilder.java new file mode 100644 index 000000000..4679eab86 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemBuilder.java @@ -0,0 +1,58 @@ +package org.schabi.newpipelegacy.local; + +import android.content.Context; +import android.widget.ImageView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.util.OnClickGesture; + +/* + * Created by Christian Schabesberger on 26.09.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * InfoItemBuilder.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 LocalItemBuilder { + private final Context context; + private final ImageLoader imageLoader = ImageLoader.getInstance(); + + private OnClickGesture onSelectedListener; + + public LocalItemBuilder(final Context context) { + this.context = context; + } + + public Context getContext() { + return context; + } + + public void displayImage(final String url, final ImageView view, + final DisplayImageOptions options) { + imageLoader.displayImage(url, view, options); + } + + public OnClickGesture getOnItemSelectedListener() { + return onSelectedListener; + } + + public void setOnItemSelectedListener(final OnClickGesture listener) { + this.onSelectedListener = listener; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemListAdapter.java new file mode 100644 index 000000000..9e9d8fb73 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/LocalItemListAdapter.java @@ -0,0 +1,342 @@ +package org.schabi.newpipelegacy.local; + +import android.content.Context; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.local.holder.LocalItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalPlaylistGridItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalPlaylistItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalPlaylistStreamGridItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalPlaylistStreamItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalStatisticStreamGridItemHolder; +import org.schabi.newpipelegacy.local.holder.LocalStatisticStreamItemHolder; +import org.schabi.newpipelegacy.local.holder.RemotePlaylistGridItemHolder; +import org.schabi.newpipelegacy.local.holder.RemotePlaylistItemHolder; +import org.schabi.newpipelegacy.util.FallbackViewHolder; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.OnClickGesture; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.List; + +/* + * Created by Christian Schabesberger on 01.08.16. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoListAdapter.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 LocalItemListAdapter extends RecyclerView.Adapter { + private static final String TAG = LocalItemListAdapter.class.getSimpleName(); + private static final boolean DEBUG = false; + + private static final int HEADER_TYPE = 0; + private static final int FOOTER_TYPE = 1; + + private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000; + private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001; + private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002; + private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004; + private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; + private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001; + private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002; + private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004; + + private final LocalItemBuilder localItemBuilder; + private final ArrayList localItems; + private final HistoryRecordManager recordManager; + private final DateFormat dateFormat; + + private boolean showFooter = false; + private boolean useGridVariant = false; + private View header = null; + private View footer = null; + + public LocalItemListAdapter(final Context context) { + recordManager = new HistoryRecordManager(context); + localItemBuilder = new LocalItemBuilder(context); + localItems = new ArrayList<>(); + dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, + Localization.getPreferredLocale(context)); + } + + public void setSelectedListener(final OnClickGesture listener) { + localItemBuilder.setOnItemSelectedListener(listener); + } + + public void unsetSelectedListener() { + localItemBuilder.setOnItemSelectedListener(null); + } + + public void addItems(@Nullable final List data) { + if (data == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "addItems() before > localItems.size() = " + + localItems.size() + ", data.size() = " + data.size()); + } + + int offsetStart = sizeConsideringHeader(); + localItems.addAll(data); + + if (DEBUG) { + Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + ", " + + "localItems.size() = " + localItems.size() + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter); + } + notifyItemRangeInserted(offsetStart, data.size()); + + if (footer != null && showFooter) { + int footerNow = sizeConsideringHeader(); + notifyItemMoved(offsetStart, footerNow); + + if (DEBUG) { + Log.d(TAG, "addItems() footer from " + offsetStart + + " to " + footerNow); + } + } + } + + public void removeItem(final LocalItem data) { + final int index = localItems.indexOf(data); + localItems.remove(index); + notifyItemRemoved(index + (header != null ? 1 : 0)); + } + + public boolean swapItems(final int fromAdapterPosition, final int toAdapterPosition) { + final int actualFrom = adapterOffsetWithoutHeader(fromAdapterPosition); + final int actualTo = adapterOffsetWithoutHeader(toAdapterPosition); + + if (actualFrom < 0 || actualTo < 0) { + return false; + } + if (actualFrom >= localItems.size() || actualTo >= localItems.size()) { + return false; + } + + localItems.add(actualTo, localItems.remove(actualFrom)); + notifyItemMoved(fromAdapterPosition, toAdapterPosition); + return true; + } + + public void clearStreamItemList() { + if (localItems.isEmpty()) { + return; + } + localItems.clear(); + notifyDataSetChanged(); + } + + public void setUseGridVariant(final boolean useGridVariant) { + this.useGridVariant = useGridVariant; + } + + public void setHeader(final View header) { + boolean changed = header != this.header; + this.header = header; + if (changed) { + notifyDataSetChanged(); + } + } + + public void setFooter(final View view) { + this.footer = view; + } + + public void showFooter(final boolean show) { + if (DEBUG) { + Log.d(TAG, "showFooter() called with: show = [" + show + "]"); + } + if (show == showFooter) { + return; + } + + showFooter = show; + if (show) { + notifyItemInserted(sizeConsideringHeader()); + } else { + notifyItemRemoved(sizeConsideringHeader()); + } + } + + private int adapterOffsetWithoutHeader(final int offset) { + return offset - (header != null ? 1 : 0); + } + + private int sizeConsideringHeader() { + return localItems.size() + (header != null ? 1 : 0); + } + + public ArrayList getItemsList() { + return localItems; + } + + @Override + public int getItemCount() { + int count = localItems.size(); + if (header != null) { + count++; + } + if (footer != null && showFooter) { + count++; + } + + if (DEBUG) { + Log.d(TAG, "getItemCount() called, count = " + count + ", " + + "localItems.size() = " + localItems.size() + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter); + } + return count; + } + + @Override + public int getItemViewType(int position) { + if (DEBUG) { + Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); + } + + if (header != null && position == 0) { + return HEADER_TYPE; + } else if (header != null) { + position--; + } + if (footer != null && position == localItems.size() && showFooter) { + return FOOTER_TYPE; + } + final LocalItem item = localItems.get(position); + + switch (item.getLocalItemType()) { + case PLAYLIST_LOCAL_ITEM: + return useGridVariant + ? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE; + case PLAYLIST_REMOTE_ITEM: + return useGridVariant + ? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE; + + case PLAYLIST_STREAM_ITEM: + return useGridVariant + ? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE; + case STATISTIC_STREAM_ITEM: + return useGridVariant + ? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE; + default: + Log.e(TAG, "No holder type has been considered for item: [" + + item.getLocalItemType() + "]"); + return -1; + } + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int type) { + if (DEBUG) { + Log.d(TAG, "onCreateViewHolder() called with: " + + "parent = [" + parent + "], type = [" + type + "]"); + } + switch (type) { + case HEADER_TYPE: + return new HeaderFooterHolder(header); + case FOOTER_TYPE: + return new HeaderFooterHolder(footer); + case LOCAL_PLAYLIST_HOLDER_TYPE: + return new LocalPlaylistItemHolder(localItemBuilder, parent); + case LOCAL_PLAYLIST_GRID_HOLDER_TYPE: + return new LocalPlaylistGridItemHolder(localItemBuilder, parent); + case REMOTE_PLAYLIST_HOLDER_TYPE: + return new RemotePlaylistItemHolder(localItemBuilder, parent); + case REMOTE_PLAYLIST_GRID_HOLDER_TYPE: + return new RemotePlaylistGridItemHolder(localItemBuilder, parent); + case STREAM_PLAYLIST_HOLDER_TYPE: + return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); + case STREAM_PLAYLIST_GRID_HOLDER_TYPE: + return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent); + case STREAM_STATISTICS_HOLDER_TYPE: + return new LocalStatisticStreamItemHolder(localItemBuilder, parent); + case STREAM_STATISTICS_GRID_HOLDER_TYPE: + return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent); + default: + Log.e(TAG, "No view type has been considered for holder: [" + type + "]"); + return new FallbackViewHolder(new View(parent.getContext())); + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { + if (DEBUG) { + Log.d(TAG, "onBindViewHolder() called with: " + + "holder = [" + holder.getClass().getSimpleName() + "], " + + "position = [" + position + "]"); + } + + if (holder instanceof LocalItemHolder) { + // If header isn't null, offset the items by -1 + if (header != null) { + position--; + } + + ((LocalItemHolder) holder) + .updateFromItem(localItems.get(position), recordManager, dateFormat); + } else if (holder instanceof HeaderFooterHolder && position == 0 && header != null) { + ((HeaderFooterHolder) holder).view = header; + } else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader() + && footer != null && showFooter) { + ((HeaderFooterHolder) holder).view = footer; + } + } + + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position, + @NonNull final List payloads) { + if (!payloads.isEmpty() && holder instanceof LocalItemHolder) { + for (Object payload : payloads) { + if (payload instanceof StreamStateEntity) { + ((LocalItemHolder) holder).updateState(localItems + .get(header == null ? position : position - 1), recordManager); + } else if (payload instanceof Boolean) { + ((LocalItemHolder) holder).updateState(localItems + .get(header == null ? position : position - 1), recordManager); + } + } + } else { + onBindViewHolder(holder, position); + } + } + + public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { + return new GridLayoutManager.SpanSizeLookup() { + @Override + public int getSpanSize(final int position) { + final int type = getItemViewType(position); + return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; + } + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipelegacy/local/bookmark/BookmarkFragment.java new file mode 100644 index 000000000..45a0184f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/bookmark/BookmarkFragment.java @@ -0,0 +1,322 @@ +package org.schabi.newpipelegacy.local.bookmark; + +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentManager; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.AppDatabase; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistLocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipelegacy.local.BaseLocalListFragment; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistManager; +import org.schabi.newpipelegacy.local.playlist.RemotePlaylistManager; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.OnClickGesture; + +import java.util.List; + +import icepick.State; +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; + +public final class BookmarkFragment extends BaseLocalListFragment, Void> { + @State + protected Parcelable itemsListState; + + private Subscription databaseSubscription; + private CompositeDisposable disposables = new CompositeDisposable(); + private LocalPlaylistManager localPlaylistManager; + private RemotePlaylistManager remotePlaylistManager; + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (activity == null) { + return; + } + final AppDatabase database = NewPipeDatabase.getInstance(activity); + localPlaylistManager = new LocalPlaylistManager(database); + remotePlaylistManager = new RemotePlaylistManager(database); + disposables = new CompositeDisposable(); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + + if (!useAsFrontPage) { + setTitle(activity.getString(R.string.tab_bookmarks)); + } + return inflater.inflate(R.layout.fragment_bookmarks, container, false); + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (activity != null && isVisibleToUser) { + setTitle(activity.getString(R.string.tab_bookmarks)); + } + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + } + + @Override + protected void initListeners() { + super.initListeners(); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(final LocalItem selectedItem) { + final FragmentManager fragmentManager = getFM(); + + if (selectedItem instanceof PlaylistMetadataEntry) { + final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid, + entry.name); + + } else if (selectedItem instanceof PlaylistRemoteEntity) { + final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); + NavigationHelper.openPlaylistFragment( + fragmentManager, + entry.getServiceId(), + entry.getUrl(), + entry.getName()); + } + } + + @Override + public void held(final LocalItem selectedItem) { + if (selectedItem instanceof PlaylistMetadataEntry) { + showLocalDialog((PlaylistMetadataEntry) selectedItem); + } else if (selectedItem instanceof PlaylistRemoteEntity) { + showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); + } + } + }); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + Flowable.combineLatest(localPlaylistManager.getPlaylists(), + remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistsSubscriber()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (disposables != null) { + disposables.clear(); + } + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + + databaseSubscription = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) { + disposables.dispose(); + } + + disposables = null; + localPlaylistManager = null; + remotePlaylistManager = null; + itemsListState = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getPlaylistsSubscriber() { + return new Subscriber>() { + @Override + public void onSubscribe(final Subscription s) { + showLoading(); + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(final List subscriptions) { + handleResult(subscriptions); + if (databaseSubscription != null) { + databaseSubscription.request(1); + } + } + + @Override + public void onError(final Throwable exception) { + BookmarkFragment.this.onError(exception); + } + + @Override + public void onComplete() { } + }; + } + + @Override + public void handleResult(@NonNull final List result) { + super.handleResult(result); + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(result); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + hideLoading(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "Bookmark", R.string.general_error); + return true; + } + + @Override + protected void resetFragment() { + super.resetFragment(); + if (disposables != null) { + disposables.clear(); + } + } + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + + private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { + showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid())); + } + + private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { + View dialogView = View.inflate(getContext(), R.layout.dialog_bookmark, null); + EditText editText = dialogView.findViewById(R.id.playlist_name_edit_text); + editText.setText(selectedItem.name); + + Builder builder = new AlertDialog.Builder(activity); + builder.setView(dialogView) + .setPositiveButton(R.string.rename_playlist, (dialog, which) -> { + changeLocalPlaylistName(selectedItem.uid, editText.getText().toString()); + }) + .setNegativeButton(R.string.cancel, null) + .setNeutralButton(R.string.delete, (dialog, which) -> { + showDeleteDialog(selectedItem.name, + localPlaylistManager.deletePlaylist(selectedItem.uid)); + dialog.dismiss(); + }) + .create() + .show(); + } + + private void showDeleteDialog(final String name, final Single deleteReactor) { + if (activity == null || disposables == null) { + return; + } + + new AlertDialog.Builder(activity) + .setTitle(name) + .setMessage(R.string.delete_playlist_prompt) + .setCancelable(true) + .setPositiveButton(R.string.delete, (dialog, i) -> + disposables.add(deleteReactor + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> { /*Do nothing on success*/ }, this::onError)) + ) + .setNegativeButton(R.string.cancel, null) + .show(); + } + + private void changeLocalPlaylistName(final long id, final String name) { + if (localPlaylistManager == null) { + return; + } + + if (DEBUG) { + Log.d(TAG, "Updating playlist id=[" + id + "] " + + "with new name=[" + name + "] items"); + } + + localPlaylistManager.renamePlaylist(id, name); + final Disposable disposable = localPlaylistManager.renamePlaylist(id, name) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(longs -> { /*Do nothing on success*/ }, this::onError); + disposables.add(disposable); + } +} + diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistAppendDialog.java new file mode 100644 index 000000000..f6eb2d335 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistAppendDialog.java @@ -0,0 +1,174 @@ +package org.schabi.newpipelegacy.local.dialog; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.local.LocalItemListAdapter; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistManager; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.util.OnClickGesture; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; + +public final class PlaylistAppendDialog extends PlaylistDialog { + private static final String TAG = PlaylistAppendDialog.class.getCanonicalName(); + + private RecyclerView playlistRecyclerView; + private LocalItemListAdapter playlistAdapter; + + private CompositeDisposable playlistDisposables = new CompositeDisposable(); + + public static PlaylistAppendDialog fromStreamInfo(final StreamInfo info) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + dialog.setInfo(Collections.singletonList(new StreamEntity(info))); + return dialog; + } + + public static PlaylistAppendDialog fromStreamInfoItems(final List items) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + List entities = new ArrayList<>(items.size()); + for (final StreamInfoItem item : items) { + entities.add(new StreamEntity(item)); + } + dialog.setInfo(entities); + return dialog; + } + + public static PlaylistAppendDialog fromPlayQueueItems(final List items) { + PlaylistAppendDialog dialog = new PlaylistAppendDialog(); + List entities = new ArrayList<>(items.size()); + for (final PlayQueueItem item : items) { + entities.add(new StreamEntity(item)); + } + dialog.setInfo(entities); + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Creation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.dialog_playlists, container); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + final LocalPlaylistManager playlistManager = + new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + + playlistAdapter = new LocalItemListAdapter(getActivity()); + playlistAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(final LocalItem selectedItem) { + if (!(selectedItem instanceof PlaylistMetadataEntry) || getStreams() == null) { + return; + } + onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, + getStreams()); + } + }); + + playlistRecyclerView = view.findViewById(R.id.playlist_list); + playlistRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + playlistRecyclerView.setAdapter(playlistAdapter); + + final View newPlaylistButton = view.findViewById(R.id.newPlaylist); + newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog()); + + playlistDisposables.add(playlistManager.getPlaylists() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::onPlaylistsReceived)); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Destruction + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onDestroyView() { + super.onDestroyView(); + playlistDisposables.dispose(); + if (playlistAdapter != null) { + playlistAdapter.unsetSelectedListener(); + } + + playlistDisposables.clear(); + playlistRecyclerView = null; + playlistAdapter = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Helper + //////////////////////////////////////////////////////////////////////////*/ + + public void openCreatePlaylistDialog() { + if (getStreams() == null || getFragmentManager() == null) { + return; + } + + PlaylistCreationDialog.newInstance(getStreams()).show(getFragmentManager(), TAG); + getDialog().dismiss(); + } + + private void onPlaylistsReceived(@NonNull final List playlists) { + if (playlists.isEmpty()) { + openCreatePlaylistDialog(); + return; + } + + if (playlistAdapter != null && playlistRecyclerView != null) { + playlistAdapter.clearStreamItemList(); + playlistAdapter.addItems(playlists); + playlistRecyclerView.setVisibility(View.VISIBLE); + } + } + + private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, + @NonNull final PlaylistMetadataEntry playlist, + @NonNull final List streams) { + if (getStreams() == null) { + return; + } + + final Toast successToast = Toast.makeText(getContext(), + R.string.playlist_add_stream_success, Toast.LENGTH_SHORT); + + if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) { + playlistDisposables.add(manager + .changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> successToast.show())); + } + + playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> successToast.show())); + + getDialog().dismiss(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistCreationDialog.java new file mode 100644 index 000000000..6fbc28a4e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistCreationDialog.java @@ -0,0 +1,63 @@ +package org.schabi.newpipelegacy.local.dialog; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.os.Bundle; +import android.view.View; +import android.widget.EditText; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistManager; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; + +public final class PlaylistCreationDialog extends PlaylistDialog { + public static PlaylistCreationDialog newInstance(final List streams) { + PlaylistCreationDialog dialog = new PlaylistCreationDialog(); + dialog.setInfo(streams); + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // Dialog + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + if (getStreams() == null) { + return super.onCreateDialog(savedInstanceState); + } + + View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null); + EditText nameInput = dialogView.findViewById(R.id.playlist_name); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()) + .setTitle(R.string.create_playlist) + .setView(dialogView) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.create, (dialogInterface, i) -> { + final String name = nameInput.getText().toString(); + final LocalPlaylistManager playlistManager = + new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + final Toast successToast = Toast.makeText(getActivity(), + R.string.playlist_creation_success, + Toast.LENGTH_SHORT); + + playlistManager.createPlaylist(name, getStreams()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(longs -> successToast.show()); + }); + + return dialogBuilder.create(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistDialog.java new file mode 100644 index 000000000..e7fca48d6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/dialog/PlaylistDialog.java @@ -0,0 +1,87 @@ +package org.schabi.newpipelegacy.local.dialog; + +import android.app.Dialog; +import android.os.Bundle; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; + +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; +import org.schabi.newpipelegacy.util.StateSaver; + +import java.util.List; +import java.util.Queue; + +public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { + private List streamEntities; + + private StateSaver.SavedState savedState; + + protected void setInfo(final List entities) { + this.streamEntities = entities; + } + + protected List getStreams() { + return streamEntities; + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + savedState = StateSaver.tryToRestore(savedInstanceState, this); + } + + @Override + public void onDestroy() { + super.onDestroy(); + StateSaver.onDestroy(savedState); + } + + @NonNull + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Dialog dialog = super.onCreateDialog(savedInstanceState); + //remove title + final Window window = dialog.getWindow(); + if (window != null) { + window.requestFeature(Window.FEATURE_NO_TITLE); + } + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public String generateSuffix() { + final int size = streamEntities == null ? 0 : streamEntities.size(); + return "." + size + ".list"; + } + + @Override + public void writeTo(final Queue objectsToSave) { + objectsToSave.add(streamEntities); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull final Queue savedObjects) { + streamEntities = (List) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (getActivity() != null) { + savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), + savedState, outState, this); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedDatabaseManager.kt new file mode 100644 index 000000000..7d41a4134 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedDatabaseManager.kt @@ -0,0 +1,168 @@ +package org.schabi.newpipelegacy.local.feed + +import android.content.Context +import android.util.Log +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.Maybe +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import java.util.Calendar +import java.util.Date +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipelegacy.MainActivity.DEBUG +import org.schabi.newpipelegacy.NewPipeDatabase +import org.schabi.newpipelegacy.database.feed.model.FeedEntity +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.database.feed.model.FeedLastUpdatedEntity +import org.schabi.newpipelegacy.database.stream.model.StreamEntity +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon + +class FeedDatabaseManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val feedTable = database.feedDAO() + private val feedGroupTable = database.feedGroupDAO() + private val streamTable = database.streamDAO() + + companion object { + /** + * Only items that are newer than this will be saved. + */ + val FEED_OLDEST_ALLOWED_DATE: Calendar = Calendar.getInstance().apply { + add(Calendar.WEEK_OF_YEAR, -13) + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + } + + fun groups() = feedGroupTable.getAll() + + fun database() = database + + fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable> { + val streams = when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() + else -> feedTable.getAllStreamsFromGroup(groupId) + } + + return streams.map> { + val items = ArrayList(it.size) + for (streamEntity in it) items.add(streamEntity.toStreamInfoItem()) + return@map items + } + } + + fun outdatedSubscriptions(outdatedThreshold: Date) = feedTable.getAllOutdated(outdatedThreshold) + + fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() + else -> feedTable.notLoadedCountForGroup(groupId) + } + } + + fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: Date) = + feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) + + fun markAsOutdated(subscriptionId: Long) = feedTable + .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) + + fun upsertAll( + subscriptionId: Long, + items: List, + oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time + ) { + val itemsToInsert = ArrayList() + loop@ for (streamItem in items) { + val uploadDate = streamItem.uploadDate + + itemsToInsert += when { + uploadDate == null && streamItem.streamType == StreamType.LIVE_STREAM -> streamItem + uploadDate != null && uploadDate.date().time >= oldestAllowedDate -> streamItem + else -> continue@loop + } + } + + feedTable.unlinkOldLivestreams(subscriptionId) + + if (itemsToInsert.isNotEmpty()) { + val streamEntities = itemsToInsert.map { StreamEntity(it) } + val streamIds = streamTable.upsertAll(streamEntities) + val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) } + + feedTable.insertAll(feedEntities) + } + + feedTable.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, Calendar.getInstance().time)) + } + + fun removeOrphansOrOlderStreams(oldestAllowedDate: Date = FEED_OLDEST_ALLOWED_DATE.time) { + feedTable.unlinkStreamsOlderThan(oldestAllowedDate) + streamTable.deleteOrphans() + } + + fun clear() { + feedTable.deleteAll() + val deletedOrphans = streamTable.deleteOrphans() + if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + } + + // ///////////////////////////////////////////////////////////////////////// + // Feed Groups + // ///////////////////////////////////////////////////////////////////////// + + fun subscriptionIdsForGroup(groupId: Long): Flowable> { + return feedGroupTable.getSubscriptionIdsFor(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { + return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun createGroup(name: String, icon: FeedGroupIcon): Maybe { + return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getGroup(groupId: Long): Maybe { + return feedGroupTable.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable { + return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun deleteGroup(groupId: Long): Completable { + return Completable.fromCallable { feedGroupTable.delete(groupId) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun updateGroupsOrder(groupIdList: List): Completable { + var index = 0L + val orderMap = groupIdList.associateBy({ it }, { index++ }) + + return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun oldestSubscriptionUpdate(groupId: Long): Flowable> { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll() + else -> feedTable.oldestSubscriptionUpdate(groupId) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedFragment.kt new file mode 100644 index 000000000..2f4018b29 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedFragment.kt @@ -0,0 +1,342 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedFragment.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.feed + +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +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 androidx.appcompat.app.AlertDialog +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.preference.PreferenceManager +import icepick.State +import java.util.Calendar +import kotlinx.android.synthetic.main.error_retry.error_button_retry +import kotlinx.android.synthetic.main.error_retry.error_message_view +import kotlinx.android.synthetic.main.fragment_feed.empty_state_view +import kotlinx.android.synthetic.main.fragment_feed.error_panel +import kotlinx.android.synthetic.main.fragment_feed.items_list +import kotlinx.android.synthetic.main.fragment_feed.loading_progress_bar +import kotlinx.android.synthetic.main.fragment_feed.loading_progress_text +import kotlinx.android.synthetic.main.fragment_feed.refresh_root_view +import kotlinx.android.synthetic.main.fragment_feed.refresh_subtitle_text +import kotlinx.android.synthetic.main.fragment_feed.refresh_text +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.fragments.list.BaseListFragment +import org.schabi.newpipelegacy.local.feed.service.FeedLoadService +import org.schabi.newpipelegacy.report.UserAction +import org.schabi.newpipelegacy.util.AnimationUtils.animateView +import org.schabi.newpipelegacy.util.Localization + +class FeedFragment : BaseListFragment() { + private lateinit var viewModel: FeedViewModel + @State + @JvmField + var listState: Parcelable? = null + + private var groupId = FeedGroupEntity.GROUP_ALL_ID + private var groupName = "" + private var oldestSubscriptionUpdate: Calendar? = null + + init { + setHasOptionsMenu(true) + setUseDefaultStateSaving(false) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) + ?: FeedGroupEntity.GROUP_ALL_ID + groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_feed, container, false) + } + + override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + + viewModel = ViewModelProviders.of(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, Observer { it?.let(::handleResult) }) + } + + override fun onPause() { + super.onPause() + listState = items_list?.layoutManager?.onSaveInstanceState() + } + + override fun onResume() { + super.onResume() + updateRelativeTimeViews() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + + if (!isVisibleToUser && view != null) { + updateRelativeTimeViews() + } + } + + override fun initListeners() { + super.initListeners() + refresh_root_view.setOnClickListener { + triggerUpdate() + } + } + + // ///////////////////////////////////////////////////////////////////////// + // Menu + // ///////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + activity.supportActionBar?.setTitle(R.string.fragment_feed_title) + activity.supportActionBar?.subtitle = groupName + + inflater.inflate(R.menu.menu_feed_fragment, menu) + + if (useAsFrontPage) { + menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == R.id.menu_item_feed_help) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + + val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + val enableDisableButtonText = when { + usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button + else -> R.string.feed_use_dedicated_fetch_method_enable_button + } + + AlertDialog.Builder(requireContext()) + .setMessage(R.string.feed_use_dedicated_fetch_method_help_text) + .setNeutralButton(enableDisableButtonText) { _, _ -> + sharedPreferences.edit() + .putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod) + .apply() + } + .setPositiveButton(resources.getString(R.string.finish), null) + .create() + .show() + return true + } + + return super.onOptionsItemSelected(item) + } + + override fun onDestroyOptionsMenu() { + super.onDestroyOptionsMenu() + activity?.supportActionBar?.subtitle = null + } + + override fun onDestroy() { + super.onDestroy() + activity?.supportActionBar?.subtitle = null + } + + // ///////////////////////////////////////////////////////////////////////// + // Handling + // ///////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + animateView(refresh_root_view, false, 0) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, true, 200) + animateView(loading_progress_text, true, 200) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun hideLoading() { + animateView(refresh_root_view, true, 200) + animateView(items_list, true, 300) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, false, 0) } + animateView(error_panel, false, 0) + } + + override fun showEmptyState() { + animateView(refresh_root_view, true, 200) + animateView(items_list, false, 0) + + animateView(loading_progress_bar, false, 0) + animateView(loading_progress_text, false, 0) + + empty_state_view?.let { animateView(it, true, 800) } + animateView(error_panel, false, 0) + } + + override fun showError(message: String, showRetryButton: Boolean) { + infoListAdapter.clearStreamItemList() + animateView(refresh_root_view, false, 120) + animateView(items_list, false, 120) + + animateView(loading_progress_bar, false, 120) + animateView(loading_progress_text, false, 120) + + error_message_view.text = message + animateView(error_button_retry, showRetryButton, if (showRetryButton) 600 else 0) + animateView(error_panel, true, 300) + } + + override fun handleResult(result: FeedState) { + when (result) { + is FeedState.ProgressState -> handleProgressState(result) + is FeedState.LoadedState -> handleLoadedState(result) + is FeedState.ErrorState -> if (handleErrorState(result)) return + } + + updateRefreshViewState() + } + + private fun handleProgressState(progressState: FeedState.ProgressState) { + showLoading() + + val isIndeterminate = progressState.currentProgress == -1 && + progressState.maxProgress == -1 + + if (!isIndeterminate) { + loading_progress_text.text = "${progressState.currentProgress}/${progressState.maxProgress}" + } else if (progressState.progressMessage > 0) { + loading_progress_text?.setText(progressState.progressMessage) + } else { + loading_progress_text?.text = "∞/∞" + } + + loading_progress_bar.isIndeterminate = isIndeterminate || + (progressState.maxProgress > 0 && progressState.currentProgress == 0) + loading_progress_bar.progress = progressState.currentProgress + + loading_progress_bar.max = progressState.maxProgress + } + + private fun handleLoadedState(loadedState: FeedState.LoadedState) { + infoListAdapter.setInfoItemList(loadedState.items) + listState?.run { + items_list.layoutManager?.onRestoreInstanceState(listState) + listState = null + } + + oldestSubscriptionUpdate = loadedState.oldestUpdate + + if (loadedState.notLoadedCount > 0) { + refresh_subtitle_text.visibility = View.VISIBLE + refresh_subtitle_text.text = getString(R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount) + } else { + refresh_subtitle_text.visibility = View.GONE + } + + if (loadedState.itemsErrors.isNotEmpty()) { + showSnackBarError(loadedState.itemsErrors, UserAction.REQUESTED_FEED, + "none", "Loading feed", R.string.general_error) + } + + if (loadedState.items.isEmpty()) { + showEmptyState() + } else { + hideLoading() + } + } + + private fun handleErrorState(errorState: FeedState.ErrorState): Boolean { + hideLoading() + errorState.error?.let { + onError(errorState.error) + return true + } + return false + } + + private fun updateRelativeTimeViews() { + updateRefreshViewState() + infoListAdapter.notifyDataSetChanged() + } + + private fun updateRefreshViewState() { + val oldestSubscriptionUpdateText = when { + oldestSubscriptionUpdate != null -> Localization.relativeTime(oldestSubscriptionUpdate!!) + else -> "—" + } + + refresh_text?.text = getString(R.string.feed_oldest_subscription_update, oldestSubscriptionUpdateText) + } + + // ///////////////////////////////////////////////////////////////////////// + // Load Service Handling + // ///////////////////////////////////////////////////////////////////////// + + override fun doInitialLoadLogic() {} + override fun reloadContent() = triggerUpdate() + override fun loadMoreItems() {} + override fun hasMoreItems() = false + + private fun triggerUpdate() { + getActivity()?.startService(Intent(requireContext(), FeedLoadService::class.java).apply { + putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) + }) + listState = null + } + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + if (useAsFrontPage) { + showSnackBarError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + onUnrecoverableError(exception, UserAction.REQUESTED_FEED, "none", "Loading Feed", 0) + return true + } + + companion object { + const val KEY_GROUP_ID = "ARG_GROUP_ID" + const val KEY_GROUP_NAME = "ARG_GROUP_NAME" + + @JvmStatic + fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment { + val feedFragment = FeedFragment() + + feedFragment.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + putString(KEY_GROUP_NAME, groupName) + } + + return feedFragment + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedState.kt new file mode 100644 index 000000000..a02f80265 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedState.kt @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.local.feed + +import androidx.annotation.StringRes +import java.util.Calendar +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +sealed class FeedState { + data class ProgressState( + val currentProgress: Int = -1, + val maxProgress: Int = -1, + @StringRes val progressMessage: Int = 0 + ) : FeedState() + + data class LoadedState( + val items: List, + val oldestUpdate: Calendar? = null, + val notLoadedCount: Long, + val itemsErrors: List = emptyList() + ) : FeedState() + + data class ErrorState( + val error: Throwable? = null + ) : FeedState() +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedViewModel.kt new file mode 100644 index 000000000..79736c5ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/FeedViewModel.kt @@ -0,0 +1,75 @@ +package org.schabi.newpipelegacy.local.feed + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.functions.Function4 +import io.reactivex.schedulers.Schedulers +import java.util.Calendar +import java.util.Date +import java.util.concurrent.TimeUnit +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.ErrorResultEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.IdleEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.ProgressEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.SuccessResultEvent +import org.schabi.newpipelegacy.util.DEFAULT_THROTTLE_TIMEOUT + +class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { + class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId) as T + } + } + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + + private val mutableStateLiveData = MutableLiveData() + val stateLiveData: LiveData = mutableStateLiveData + + private var combineDisposable = Flowable + .combineLatest( + FeedEventManager.events(), + feedDatabaseManager.asStreamItems(groupId), + feedDatabaseManager.notLoadedCount(groupId), + feedDatabaseManager.oldestSubscriptionUpdate(groupId), + + Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List -> + return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) + } + ) + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + val (event, listFromDB, notLoadedCount, oldestUpdate) = it + + val oldestUpdateCalendar = + oldestUpdate?.let { Calendar.getInstance().apply { time = it } } + + mutableStateLiveData.postValue(when (event) { + is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount) + is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) + is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdateCalendar, notLoadedCount, event.itemsErrors) + is ErrorResultEvent -> FeedState.ErrorState(event.error) + }) + + if (event is ErrorResultEvent || event is SuccessResultEvent) { + FeedEventManager.reset() + } + } + + override fun onCleared() { + super.onCleared() + combineDisposable.dispose() + } + + private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: Date?) +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedEventManager.kt new file mode 100644 index 000000000..cb8831249 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedEventManager.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipelegacy.local.feed.service + +import androidx.annotation.StringRes +import io.reactivex.Flowable +import io.reactivex.processors.BehaviorProcessor +import java.util.concurrent.atomic.AtomicBoolean +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.IdleEvent + +object FeedEventManager { + private var processor: BehaviorProcessor = BehaviorProcessor.create() + private var ignoreUpstream = AtomicBoolean() + private var eventsFlowable = processor.startWith(IdleEvent) + + fun postEvent(event: Event) { + processor.onNext(event) + } + + fun events(): Flowable { + return eventsFlowable.filter { !ignoreUpstream.get() } + } + + fun reset() { + ignoreUpstream.set(true) + postEvent(IdleEvent) + ignoreUpstream.set(false) + } + + sealed class Event { + object IdleEvent : Event() + data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { + constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) + } + + data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event() + data class ErrorResultEvent(val error: Throwable) : Event() + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedLoadService.kt new file mode 100644 index 000000000..72e601be8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/feed/service/FeedLoadService.kt @@ -0,0 +1,466 @@ +/* + * Copyright 2019 Mauricio Colli + * FeedLoadService.kt is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.feed.service + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import android.preference.PreferenceManager +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import io.reactivex.Flowable +import io.reactivex.Notification +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.functions.Consumer +import io.reactivex.functions.Function +import io.reactivex.processors.PublishProcessor +import io.reactivex.schedulers.Schedulers +import java.io.IOException +import java.util.Calendar +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipelegacy.MainActivity.DEBUG +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.feed.FeedDatabaseManager +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.ErrorResultEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.IdleEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.ProgressEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.Event.SuccessResultEvent +import org.schabi.newpipelegacy.local.feed.service.FeedEventManager.postEvent +import org.schabi.newpipelegacy.local.subscription.SubscriptionManager +import org.schabi.newpipelegacy.util.ExceptionUtils +import org.schabi.newpipelegacy.util.ExtractorHelper + +class FeedLoadService : Service() { + companion object { + private val TAG = FeedLoadService::class.java.simpleName + private const val NOTIFICATION_ID = 7293450 + private const val ACTION_CANCEL = "org.schabi.newpipe.local.feed.service.FeedLoadService.CANCEL" + + /** + * How often the notification will be updated. + */ + private const val NOTIFICATION_SAMPLING_PERIOD = 1500 + + /** + * How many extractions will be running in parallel. + */ + private const val PARALLEL_EXTRACTIONS = 6 + + /** + * Number of items to buffer to mass-insert in the database. + */ + private const val BUFFER_COUNT_BEFORE_INSERT = 20 + + const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" + } + + private var loadingSubscription: Subscription? = null + private lateinit var subscriptionManager: SubscriptionManager + + private lateinit var feedDatabaseManager: FeedDatabaseManager + private lateinit var feedResultsHolder: ResultsHolder + + private var disposables = CompositeDisposable() + private var notificationUpdater = PublishProcessor.create() + + // ///////////////////////////////////////////////////////////////////////// + // Lifecycle + // ///////////////////////////////////////////////////////////////////////// + + override fun onCreate() { + super.onCreate() + subscriptionManager = SubscriptionManager(this) + feedDatabaseManager = FeedDatabaseManager(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "]," + + " flags = [" + flags + "], startId = [" + startId + "]") + } + + if (intent == null || loadingSubscription != null) { + return START_NOT_STICKY + } + + setupNotification() + setupBroadcastReceiver() + val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + + val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) + val useFeedExtractor = defaultSharedPreferences + .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + + val thresholdOutdatedSecondsString = defaultSharedPreferences + .getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value)) + val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt() + + startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds) + + return START_NOT_STICKY + } + + private fun disposeAll() { + unregisterReceiver(broadcastReceiver) + + loadingSubscription?.cancel() + loadingSubscription = null + + disposables.dispose() + } + + private fun stopService() { + disposeAll() + stopForeground(true) + notificationManager.cancel(NOTIFICATION_ID) + stopSelf() + } + + override fun onBind(intent: Intent): IBinder? { + return null + } + + // ///////////////////////////////////////////////////////////////////////// + // Loading & Handling + // ///////////////////////////////////////////////////////////////////////// + + private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { + companion object { + fun wrapList(subscriptionId: Long, info: ListInfo): List { + val toReturn = ArrayList(info.errors.size) + for (error in info.errors) { + toReturn.add(RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, error)) + } + return toReturn + } + } + } + + private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) { + feedResultsHolder = ResultsHolder() + + val outdatedThreshold = Calendar.getInstance().apply { + add(Calendar.SECOND, -thresholdOutdatedSeconds) + }.time + + val subscriptions = when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) + else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) + } + + subscriptions + .limit(1) + + .doOnNext { + currentProgress.set(0) + maxProgress.set(it.size) + } + .filter { it.isNotEmpty() } + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext { + startForeground(NOTIFICATION_ID, notificationBuilder.build()) + updateNotificationProgress(null) + broadcastProgress() + } + + .observeOn(Schedulers.io()) + .flatMap { Flowable.fromIterable(it) } + .takeWhile { !cancelSignal.get() } + + .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) + .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) + .filter { !cancelSignal.get() } + + .map { subscriptionEntity -> + try { + val listInfo = if (useFeedExtractor) { + ExtractorHelper + .getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url) + .blockingGet() + } else { + ExtractorHelper + .getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true) + .blockingGet() + } as ListInfo + + return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo)) + } catch (e: Throwable) { + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = RequestException(subscriptionEntity.uid, request, e) + return@map Notification.createOnError>>(wrapper) + } + } + .sequential() + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(errorHandlingConsumer) + + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(notificationsConsumer) + + .observeOn(Schedulers.io()) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .doOnNext(databaseConsumer) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(resultSubscriber) + } + + private fun broadcastProgress() { + postEvent(ProgressEvent(currentProgress.get(), maxProgress.get())) + } + + private val resultSubscriber + get() = object : Subscriber>>>> { + + override fun onSubscribe(s: Subscription) { + loadingSubscription = s + s.request(java.lang.Long.MAX_VALUE) + } + + override fun onNext(notification: List>>>) { + if (DEBUG) Log.v(TAG, "onNext() → $notification") + } + + override fun onError(error: Throwable) { + handleError(error) + } + + override fun onComplete() { + if (maxProgress.get() == 0) { + postEvent(IdleEvent) + stopService() + + return + } + + currentProgress.set(-1) + maxProgress.set(-1) + + notificationUpdater.onNext(getString(R.string.feed_processing_message)) + postEvent(ProgressEvent(R.string.feed_processing_message)) + + disposables.add(Single + .fromCallable { + feedResultsHolder.ready() + + postEvent(ProgressEvent(R.string.feed_processing_message)) + feedDatabaseManager.removeOrphansOrOlderStreams() + + postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors)) + true + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { _, throwable -> + if (throwable != null) { + Log.e(TAG, "Error while storing result", throwable) + handleError(throwable) + return@subscribe + } + stopService() + }) + } + } + + private val databaseConsumer: Consumer>>>> + get() = Consumer { + feedDatabaseManager.database().runInTransaction { + for (notification in it) { + + if (notification.isOnNext) { + val subscriptionId = notification.value!!.first + val info = notification.value!!.second + + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + subscriptionManager.updateFromInfo(subscriptionId, info) + + if (info.errors.isNotEmpty()) { + feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info)) + feedDatabaseManager.markAsOutdated(subscriptionId) + } + } else if (notification.isOnError) { + val error = notification.error!! + feedResultsHolder.addError(error) + + if (error is RequestException) { + feedDatabaseManager.markAsOutdated(error.subscriptionId) + } + } + } + } + } + + private val errorHandlingConsumer: Consumer>>> + get() = Consumer { + if (it.isOnError) { + var error = it.error!! + if (error is RequestException) error = error.cause!! + val cause = error.cause + + when { + error is ReCaptchaException -> throw error + cause is ReCaptchaException -> throw cause + + error is IOException -> throw error + cause is IOException -> throw cause + ExceptionUtils.isNetworkRelated(error) -> throw IOException(error) + } + } + } + + private val notificationsConsumer: Consumer>>> + get() = Consumer { onItemCompleted(it.value?.second?.name) } + + private fun onItemCompleted(updateDescription: String?) { + currentProgress.incrementAndGet() + notificationUpdater.onNext(updateDescription ?: "") + + broadcastProgress() + } + + // ///////////////////////////////////////////////////////////////////////// + // Notification + // ///////////////////////////////////////////////////////////////////////// + + private lateinit var notificationManager: NotificationManagerCompat + private lateinit var notificationBuilder: NotificationCompat.Builder + + private var currentProgress = AtomicInteger(-1) + private var maxProgress = AtomicInteger(-1) + + private fun createNotification(): NotificationCompat.Builder { + val cancelActionIntent = PendingIntent.getBroadcast(this, + NOTIFICATION_ID, Intent(ACTION_CANCEL), 0) + + return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .addAction(0, getString(R.string.cancel), cancelActionIntent) + .setContentTitle(getString(R.string.feed_notification_loading)) + } + + private fun setupNotification() { + notificationManager = NotificationManagerCompat.from(this) + notificationBuilder = createNotification() + + val throttleAfterFirstEmission = Function { flow: Flowable -> + flow.limit(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) + } + + disposables.add(notificationUpdater + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotificationProgress)) + } + + private fun updateNotificationProgress(updateDescription: String?) { + notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) + + if (maxProgress.get() == -1) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + notificationBuilder.setContentText(updateDescription) + } else { + val progressText = this.currentProgress.toString() + "/" + maxProgress + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)") + } else { + notificationBuilder.setContentInfo(progressText) + if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription) + } + } + + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) + } + + // ///////////////////////////////////////////////////////////////////////// + // Notification Actions + // ///////////////////////////////////////////////////////////////////////// + + private lateinit var broadcastReceiver: BroadcastReceiver + private val cancelSignal = AtomicBoolean() + + private fun setupBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == ACTION_CANCEL) { + cancelSignal.set(true) + } + } + } + registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL)) + } + + // ///////////////////////////////////////////////////////////////////////// + // Error handling + // ///////////////////////////////////////////////////////////////////////// + + private fun handleError(error: Throwable) { + postEvent(ErrorResultEvent(error)) + stopService() + } + + // ///////////////////////////////////////////////////////////////////////// + // Results Holder + // ///////////////////////////////////////////////////////////////////////// + + class ResultsHolder { + /** + * List of errors that may have happen during loading. + */ + internal lateinit var itemsErrors: List + + private val itemsErrorsHolder: MutableList = ArrayList() + + fun addError(error: Throwable) { + itemsErrorsHolder.add(error) + } + + fun addErrors(errors: List) { + itemsErrorsHolder.addAll(errors) + } + + fun ready() { + itemsErrors = itemsErrorsHolder.toList() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryEntryAdapter.java new file mode 100644 index 000000000..5802bab2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryEntryAdapter.java @@ -0,0 +1,108 @@ +package org.schabi.newpipelegacy.local.history; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.util.Localization; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; + + +/** + * This is an adapter for history entries. + * + * @param the type of the entries + * @param the type of the view holder + */ +public abstract class HistoryEntryAdapter + extends RecyclerView.Adapter { + private final ArrayList mEntries; + private final DateFormat mDateFormat; + private final Context mContext; + private OnHistoryItemClickListener onHistoryItemClickListener = null; + + public HistoryEntryAdapter(final Context context) { + super(); + mContext = context; + mEntries = new ArrayList<>(); + mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, + Localization.getPreferredLocale(context)); + } + + public void setEntries(@NonNull final Collection historyEntries) { + mEntries.clear(); + mEntries.addAll(historyEntries); + notifyDataSetChanged(); + } + + public Collection getItems() { + return mEntries; + } + + public void clear() { + mEntries.clear(); + notifyDataSetChanged(); + } + + protected String getFormattedDate(final Date date) { + return mDateFormat.format(date); + } + + protected String getFormattedViewString(final long viewCount) { + return Localization.shortViewCount(mContext, viewCount); + } + + @Override + public int getItemCount() { + return mEntries.size(); + } + + @Override + public void onBindViewHolder(final VH holder, final int position) { + final E entry = mEntries.get(position); + holder.itemView.setOnClickListener(v -> { + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemClick(entry); + } + }); + + holder.itemView.setOnLongClickListener(view -> { + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener.onHistoryItemLongClick(entry); + return true; + } + return false; + }); + + onBindViewHolder(holder, entry, position); + } + + @Override + public void onViewRecycled(final VH holder) { + super.onViewRecycled(holder); + holder.itemView.setOnClickListener(null); + } + + abstract void onBindViewHolder(VH holder, E entry, int position); + + public void setOnHistoryItemClickListener( + @Nullable final OnHistoryItemClickListener onHistoryItemClickListener) { + this.onHistoryItemClickListener = onHistoryItemClickListener; + } + + public boolean isEmpty() { + return mEntries.isEmpty(); + } + + public interface OnHistoryItemClickListener { + void onHistoryItemClick(E item); + + void onHistoryItemLongClick(E item); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryRecordManager.java new file mode 100644 index 000000000..4517df50e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/history/HistoryRecordManager.java @@ -0,0 +1,314 @@ +package org.schabi.newpipelegacy.local.history; + +/* + * Copyright (C) Mauricio Colli 2018 + * HistoryRecordManager.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 . + */ + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.AppDatabase; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.history.dao.SearchHistoryDAO; +import org.schabi.newpipelegacy.database.history.dao.StreamHistoryDAO; +import org.schabi.newpipelegacy.database.history.model.SearchHistoryEntry; +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntity; +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntry; +import org.schabi.newpipelegacy.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry; +import org.schabi.newpipelegacy.database.stream.dao.StreamDAO; +import org.schabi.newpipelegacy.database.stream.dao.StreamStateDAO; +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; + +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class HistoryRecordManager { + private final AppDatabase database; + private final StreamDAO streamTable; + private final StreamHistoryDAO streamHistoryTable; + private final SearchHistoryDAO searchHistoryTable; + private final StreamStateDAO streamStateTable; + private final SharedPreferences sharedPreferences; + private final String searchHistoryKey; + private final String streamHistoryKey; + + public HistoryRecordManager(final Context context) { + database = NewPipeDatabase.getInstance(context); + streamTable = database.streamDAO(); + streamHistoryTable = database.streamHistoryDAO(); + searchHistoryTable = database.searchHistoryDAO(); + streamStateTable = database.streamStateDAO(); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + searchHistoryKey = context.getString(R.string.enable_search_history_key); + streamHistoryKey = context.getString(R.string.enable_watch_history_key); + } + + /////////////////////////////////////////////////////// + // Watch History + /////////////////////////////////////////////////////// + + public Maybe onViewed(final StreamInfo info) { + if (!isStreamHistoryEnabled()) { + return Maybe.empty(); + } + + final Date currentTime = new Date(); + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); + + if (latestEntry != null) { + streamHistoryTable.delete(latestEntry); + latestEntry.setAccessDate(currentTime); + latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); + return streamHistoryTable.insert(latestEntry); + } else { + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final long streamId) { + return Single.fromCallable(() -> streamHistoryTable.deleteStreamHistory(streamId)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteWholeStreamHistory() { + return Single.fromCallable(streamHistoryTable::deleteAll) + .subscribeOn(Schedulers.io()); + } + + public Single deleteCompelteStreamStateHistory() { + return Single.fromCallable(streamStateTable::deleteAll) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamHistory() { + return streamHistoryTable.getHistory().subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamHistorySortedById() { + return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io()); + } + + public Flowable> getStreamStatistics() { + return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); + } + + public Single> insertStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.insertAll(entities)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteStreamHistory(final Collection entries) { + List entities = new ArrayList<>(entries.size()); + for (final StreamHistoryEntry entry : entries) { + entities.add(entry.toStreamHistoryEntity()); + } + return Single.fromCallable(() -> streamHistoryTable.delete(entities)) + .subscribeOn(Schedulers.io()); + } + + private boolean isStreamHistoryEnabled() { + return sharedPreferences.getBoolean(streamHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Search History + /////////////////////////////////////////////////////// + + public Maybe onSearched(final int serviceId, final String search) { + if (!isSearchHistoryEnabled()) { + return Maybe.empty(); + } + + final Date currentTime = new Date(); + final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); + + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); + if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { + latestEntry.setCreationDate(currentTime); + return (long) searchHistoryTable.update(latestEntry); + } else { + return searchHistoryTable.insert(newEntry); + } + })).subscribeOn(Schedulers.io()); + } + + public Single deleteSearchHistory(final String search) { + return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) + .subscribeOn(Schedulers.io()); + } + + public Single deleteCompleteSearchHistory() { + return Single.fromCallable(searchHistoryTable::deleteAll) + .subscribeOn(Schedulers.io()); + } + + public Flowable> getRelatedSearches(final String query, + final int similarQueryLimit, + final int uniqueQueryLimit) { + return query.length() > 0 + ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) + : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); + } + + private boolean isSearchHistoryEnabled() { + return sharedPreferences.getBoolean(searchHistoryKey, false); + } + + /////////////////////////////////////////////////////// + // Stream State History + /////////////////////////////////////////////////////// + + public Maybe getStreamHistory(final StreamInfo info) { + return Maybe.fromCallable(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + return streamHistoryTable.getLatestEntry(streamId); + }).subscribeOn(Schedulers.io()); + } + + public Maybe loadStreamState(final PlayQueueItem queueItem) { + return queueItem.getStream() + .map((info) -> streamTable.upsert(new StreamEntity(info))) + .flatMapPublisher(streamStateTable::getState) + .firstElement() + .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) + .filter(state -> state.isValid((int) queueItem.getDuration())) + .subscribeOn(Schedulers.io()); + } + + public Maybe loadStreamState(final StreamInfo info) { + return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) + .flatMapPublisher(streamStateTable::getState) + .firstElement() + .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) + .filter(state -> state.isValid((int) info.getDuration())) + .subscribeOn(Schedulers.io()); + } + + public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + return Completable.fromAction(() -> database.runInTransaction(() -> { + final long streamId = streamTable.upsert(new StreamEntity(info)); + final StreamStateEntity state = new StreamStateEntity(streamId, progressTime); + if (state.isValid((int) info.getDuration())) { + streamStateTable.upsert(state); + } else { + streamStateTable.deleteState(streamId); + } + })).subscribeOn(Schedulers.io()); + } + + public Single loadStreamState(final InfoItem info) { + return Single.fromCallable(() -> { + final List entities = streamTable + .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); + if (entities.isEmpty()) { + return new StreamStateEntity[]{null}; + } + final List states = streamStateTable + .getState(entities.get(0).getUid()).blockingFirst(); + if (states.isEmpty()) { + return new StreamStateEntity[]{null}; + } + return new StreamStateEntity[]{states.get(0)}; + }).subscribeOn(Schedulers.io()); + } + + public Single> loadStreamStateBatch(final List infos) { + return Single.fromCallable(() -> { + final List result = new ArrayList<>(infos.size()); + for (InfoItem info : infos) { + final List entities = streamTable + .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); + if (entities.isEmpty()) { + result.add(null); + continue; + } + final List states = streamStateTable + .getState(entities.get(0).getUid()).blockingFirst(); + if (states.isEmpty()) { + result.add(null); + continue; + } + result.add(states.get(0)); + } + return result; + }).subscribeOn(Schedulers.io()); + } + + public Single> loadLocalStreamStateBatch( + final List items) { + return Single.fromCallable(() -> { + final List result = new ArrayList<>(items.size()); + for (LocalItem item : items) { + long streamId; + if (item instanceof StreamStatisticsEntry) { + streamId = ((StreamStatisticsEntry) item).getStreamId(); + } else if (item instanceof PlaylistStreamEntity) { + streamId = ((PlaylistStreamEntity) item).getStreamUid(); + } else if (item instanceof PlaylistStreamEntry) { + streamId = ((PlaylistStreamEntry) item).getStreamId(); + } else { + result.add(null); + continue; + } + final List states = streamStateTable.getState(streamId) + .blockingFirst(); + if (states.isEmpty()) { + result.add(null); + continue; + } + result.add(states.get(0)); + } + return result; + }).subscribeOn(Schedulers.io()); + } + + /////////////////////////////////////////////////////// + // Utility + /////////////////////////////////////////////////////// + + public Single removeOrphanedRecords() { + return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); + } + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipelegacy/local/history/StatisticsPlaylistFragment.java new file mode 100644 index 000000000..c7e5b8f5c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/history/StatisticsPlaylistFragment.java @@ -0,0 +1,472 @@ +package org.schabi.newpipelegacy.local.history; + +import android.app.Activity; +import android.content.Context; +import android.os.Bundle; +import android.os.Parcelable; +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.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.snackbar.Snackbar; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.info_list.InfoItemDialog; +import org.schabi.newpipelegacy.local.BaseLocalListFragment; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.settings.SettingsActivity; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.OnClickGesture; +import org.schabi.newpipelegacy.util.StreamDialogEntry; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import icepick.State; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; + +public class StatisticsPlaylistFragment + extends BaseLocalListFragment, Void> { + private final CompositeDisposable disposables = new CompositeDisposable(); + @State + Parcelable itemsListState; + private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED; + private View headerPlayAllButton; + private View headerPopupButton; + private View headerBackgroundButton; + private View playlistCtrl; + private View sortButton; + private ImageView sortButtonIcon; + private TextView sortButtonText; + /* Used for independent events */ + private Subscription databaseSubscription; + private HistoryRecordManager recordManager; + + private List processResult(final List results) { + switch (sortMode) { + case LAST_PLAYED: + Collections.sort(results, (left, right) -> + right.getLatestAccessDate().compareTo(left.getLatestAccessDate())); + return results; + case MOST_PLAYED: + Collections.sort(results, (left, right) -> + Long.compare(right.getWatchCount(), left.getWatchCount())); + return results; + default: + return null; + } + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + recordManager = new HistoryRecordManager(getContext()); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_playlist, container, false); + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (activity != null && isVisibleToUser) { + setTitle(activity.getString(R.string.title_activity_history)); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.menu_history, menu); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + if (!useAsFrontPage) { + setTitle(getString(R.string.title_last_played)); + } + } + + @Override + protected View getListHeader() { + final View headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.statistic_playlist_control, itemsList, false); + playlistCtrl = headerRootLayout.findViewById(R.id.playlist_control); + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + sortButton = headerRootLayout.findViewById(R.id.sortButton); + sortButtonIcon = headerRootLayout.findViewById(R.id.sortButtonIcon); + sortButtonText = headerRootLayout.findViewById(R.id.sortButtonText); + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(final LocalItem selectedItem) { + if (selectedItem instanceof StreamStatisticsEntry) { + final StreamStatisticsEntry item = (StreamStatisticsEntry) selectedItem; + NavigationHelper.openVideoDetailFragment(getFM(), + item.getStreamEntity().getServiceId(), + item.getStreamEntity().getUrl(), + item.getStreamEntity().getTitle()); + } + } + + @Override + public void held(final LocalItem selectedItem) { + if (selectedItem instanceof StreamStatisticsEntry) { + showStreamDialog((StreamStatisticsEntry) selectedItem); + } + } + }); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_history_clear: + new AlertDialog.Builder(activity) + .setTitle(R.string.delete_view_history_alert) + .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) + .setPositiveButton(R.string.delete, ((dialog, which) -> { + final Disposable onDelete = recordManager.deleteWholeStreamHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> Toast.makeText(getContext(), + R.string.watch_history_deleted, + Toast.LENGTH_SHORT).show(), + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete view history", + R.string.general_error))); + + final Disposable onClearOrphans = recordManager.removeOrphanedRecords() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> { + }, + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete search history", + R.string.general_error))); + disposables.add(onClearOrphans); + disposables.add(onDelete); + })) + .create() + .show(); + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + recordManager.getStreamStatistics() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHistoryObserver()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (itemListAdapter != null) { + itemListAdapter.unsetSelectedListener(); + } + if (headerBackgroundButton != null) { + headerBackgroundButton.setOnClickListener(null); + } + if (headerPlayAllButton != null) { + headerPlayAllButton.setOnClickListener(null); + } + if (headerPopupButton != null) { + headerPopupButton.setOnClickListener(null); + } + + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + databaseSubscription = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + recordManager = null; + itemsListState = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Statistics Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getHistoryObserver() { + return new Subscriber>() { + @Override + public void onSubscribe(final Subscription s) { + showLoading(); + + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(final List streams) { + handleResult(streams); + if (databaseSubscription != null) { + databaseSubscription.request(1); + } + } + + @Override + public void onError(final Throwable exception) { + StatisticsPlaylistFragment.this.onError(exception); + } + + @Override + public void onComplete() { + } + }; + } + + @Override + public void handleResult(@NonNull final List result) { + super.handleResult(result); + if (itemListAdapter == null) { + return; + } + + playlistCtrl.setVisibility(View.VISIBLE); + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(processResult(result)); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); + sortButton.setOnClickListener(view -> toggleSortMode()); + + hideLoading(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void resetFragment() { + super.resetFragment(); + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + } + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "History Statistics", R.string.general_error); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void toggleSortMode() { + if (sortMode == StatisticSortMode.LAST_PLAYED) { + sortMode = StatisticSortMode.MOST_PLAYED; + setTitle(getString(R.string.title_most_played)); + sortButtonIcon.setImageResource( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_history)); + sortButtonText.setText(R.string.title_last_played); + } else { + sortMode = StatisticSortMode.LAST_PLAYED; + setTitle(getString(R.string.title_last_played)); + sortButtonIcon.setImageResource( + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_filter_list)); + sortButtonText.setText(R.string.title_most_played); + } + startLoading(true); + } + + private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { + return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); + } + + private void showStreamDialog(final StreamStatisticsEntry item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || activity == null) { + return; + } + final StreamInfoItem infoItem = item.toStreamInfoItem(); + + if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.delete, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + } else { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.enqueue_on_popup, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.delete, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + + StreamDialogEntry.start_here_on_popup.setCustomAction((fragment, infoItemDuplicate) -> + NavigationHelper + .playOnPopupPlayer(context, getPlayQueueStartingAt(item), true)); + } + + StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) -> + NavigationHelper + .playOnBackgroundPlayer(context, getPlayQueueStartingAt(item), true)); + StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) -> + deleteEntry(Math.max(itemListAdapter.getItemsList().indexOf(item), 0))); + + new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), + (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show(); + } + + private void deleteEntry(final int index) { + final LocalItem infoItem = itemListAdapter.getItemsList() + .get(index); + if (infoItem instanceof StreamStatisticsEntry) { + final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; + final Disposable onDelete = recordManager.deleteStreamHistory(entry.getStreamId()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> { + if (getView() != null) { + Snackbar.make(getView(), R.string.one_item_deleted, + Snackbar.LENGTH_SHORT).show(); + } else { + Toast.makeText(getContext(), + R.string.one_item_deleted, + Toast.LENGTH_SHORT).show(); + } + }, + throwable -> showSnackBarError(throwable, + UserAction.DELETE_FROM_HISTORY, "none", + "Deleting item failed", R.string.general_error)); + + disposables.add(onDelete); + } + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + if (itemListAdapter == null) { + return new SinglePlayQueue(Collections.emptyList(), 0); + } + + final List infoItems = itemListAdapter.getItemsList(); + List streamInfoItems = new ArrayList<>(infoItems.size()); + for (final LocalItem item : infoItems) { + if (item instanceof StreamStatisticsEntry) { + streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem()); + } + } + return new SinglePlayQueue(streamInfoItems, index); + } + + private enum StatisticSortMode { + LAST_PLAYED, + MOST_PLAYED, + } +} + diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalItemHolder.java new file mode 100644 index 000000000..72b98c4ce --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalItemHolder.java @@ -0,0 +1,48 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; + +import java.text.DateFormat; + +/* + * Created by Christian Schabesberger on 12.02.17. + * + * Copyright (C) Christian Schabesberger 2016 + * InfoItemHolder.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 abstract class LocalItemHolder extends RecyclerView.ViewHolder { + protected final LocalItemBuilder itemBuilder; + + public LocalItemHolder(final LocalItemBuilder itemBuilder, final int layoutId, + final ViewGroup parent) { + super(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false)); + this.itemBuilder = itemBuilder; + } + + public abstract void updateFromItem(LocalItem item, HistoryRecordManager historyRecordManager, + DateFormat dateFormat); + + public void updateState(final LocalItem localItem, + final HistoryRecordManager historyRecordManager) { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistGridItemHolder.java new file mode 100644 index 000000000..5b42b6c83 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistGridItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.local.LocalItemBuilder; + +public class LocalPlaylistGridItemHolder extends LocalPlaylistItemHolder { + public LocalPlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistItemHolder.java new file mode 100644 index 000000000..e0ebef244 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistItemHolder.java @@ -0,0 +1,44 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.View; +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; + +import java.text.DateFormat; + +public class LocalPlaylistItemHolder extends PlaylistItemHolder { + public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, parent); + } + + LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistMetadataEntry)) { + return; + } + final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; + + itemTitleView.setText(item.name); + itemStreamCountView.setText(Localization.localizeStreamCountMini( + itemStreamCountView.getContext(), item.streamCount)); + itemUploaderView.setVisibility(View.INVISIBLE); + + itemBuilder.displayImage(item.thumbnailUrl, itemThumbnailView, + ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); + + super.updateFromItem(localItem, historyRecordManager, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamGridItemHolder.java new file mode 100644 index 000000000..a5c4d0957 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamGridItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.local.LocalItemBuilder; + +public class LocalPlaylistStreamGridItemHolder extends LocalPlaylistStreamItemHolder { + public LocalPlaylistStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent); // TODO + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamItemHolder.java new file mode 100644 index 000000000..8441805bf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalPlaylistStreamItemHolder.java @@ -0,0 +1,149 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.views.AnimatedProgressBar; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +public class LocalPlaylistStreamItemHolder extends LocalItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + private final TextView itemAdditionalDetailsView; + public final TextView itemDurationView; + private final View itemHandleView; + private final AnimatedProgressBar itemProgressView; + + LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemHandleView = itemView.findViewById(R.id.itemHandle); + itemProgressView = itemView.findViewById(R.id.itemProgressView); + } + + public LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_stream_playlist_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistStreamEntry)) { + return; + } + final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; + + itemVideoTitleView.setText(item.getStreamEntity().getTitle()); + itemAdditionalDetailsView.setText(Localization + .concatenateStrings(item.getStreamEntity().getUploader(), + NewPipe.getNameOfService(item.getStreamEntity().getServiceId()))); + + if (item.getStreamEntity().getDuration() > 0) { + itemDurationView.setText(Localization + .getDurationString(item.getStreamEntity().getDuration())); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + + StreamStateEntity state = historyRecordManager + .loadLocalStreamStateBatch(new ArrayList() {{ + add(localItem); + }}).blockingGet().get(0); + if (state != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } + } else { + itemDurationView.setVisibility(View.GONE); + } + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + + itemThumbnailView.setOnTouchListener(getOnTouchListener(item)); + itemHandleView.setOnTouchListener(getOnTouchListener(item)); + } + + @Override + public void updateState(final LocalItem localItem, + final HistoryRecordManager historyRecordManager) { + if (!(localItem instanceof PlaylistStreamEntry)) { + return; + } + final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; + + StreamStateEntity state = historyRecordManager + .loadLocalStreamStateBatch(new ArrayList() {{ + add(localItem); + }}).blockingGet().get(0); + if (state != null && item.getStreamEntity().getDuration() > 0) { + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } + + private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { + return (view, motionEvent) -> { + view.performClick(); + if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null + && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + itemBuilder.getOnItemSelectedListener().drag(item, + LocalPlaylistStreamItemHolder.this); + } + return false; + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamGridItemHolder.java new file mode 100644 index 000000000..6f3d2a30b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamGridItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.local.LocalItemBuilder; + +public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder { + public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamItemHolder.java new file mode 100644 index 000000000..33d3e2682 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/LocalStatisticStreamItemHolder.java @@ -0,0 +1,167 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.stream.StreamStatisticsEntry; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.views.AnimatedProgressBar; + +import java.text.DateFormat; +import java.util.ArrayList; +import java.util.concurrent.TimeUnit; + +/* + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * StreamInfoItemHolder.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 LocalStatisticStreamItemHolder extends LocalItemHolder { + public final ImageView itemThumbnailView; + public final TextView itemVideoTitleView; + public final TextView itemUploaderView; + public final TextView itemDurationView; + @Nullable + public final TextView itemAdditionalDetails; + private final AnimatedProgressBar itemProgressView; + + public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder, + final ViewGroup parent) { + this(itemBuilder, R.layout.list_stream_item, parent); + } + + LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + itemDurationView = itemView.findViewById(R.id.itemDurationView); + itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); + itemProgressView = itemView.findViewById(R.id.itemProgressView); + } + + private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, + final DateFormat dateFormat) { + final String watchCount = Localization + .shortViewCount(itemBuilder.getContext(), entry.getWatchCount()); + final String uploadDate = dateFormat.format(entry.getLatestAccessDate()); + final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId()); + return Localization.concatenateStrings(watchCount, uploadDate, serviceName); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateFormat dateFormat) { + if (!(localItem instanceof StreamStatisticsEntry)) { + return; + } + final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; + + itemVideoTitleView.setText(item.getStreamEntity().getTitle()); + itemUploaderView.setText(item.getStreamEntity().getUploader()); + + if (item.getStreamEntity().getDuration() > 0) { + itemDurationView. + setText(Localization.getDurationString(item.getStreamEntity().getDuration())); + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)); + itemDurationView.setVisibility(View.VISIBLE); + + StreamStateEntity state = historyRecordManager + .loadLocalStreamStateBatch(new ArrayList() {{ + add(localItem); + }}).blockingGet().get(0); + if (state != null) { + itemProgressView.setVisibility(View.VISIBLE); + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + } else { + itemProgressView.setVisibility(View.GONE); + } + } else { + itemDurationView.setVisibility(View.GONE); + itemProgressView.setVisibility(View.GONE); + } + + if (itemAdditionalDetails != null) { + itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateFormat)); + } + + // Default thumbnail is shown on error, while loading and if the url is empty + itemBuilder.displayImage(item.getStreamEntity().getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item); + } + return true; + }); + } + + @Override + public void updateState(final LocalItem localItem, + final HistoryRecordManager historyRecordManager) { + if (!(localItem instanceof StreamStatisticsEntry)) { + return; + } + final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; + + StreamStateEntity state = historyRecordManager + .loadLocalStreamStateBatch(new ArrayList() {{ + add(localItem); + }}).blockingGet().get(0); + if (state != null && item.getStreamEntity().getDuration() > 0) { + itemProgressView.setMax((int) item.getStreamEntity().getDuration()); + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + } else { + itemProgressView.setProgress((int) TimeUnit.MILLISECONDS + .toSeconds(state.getProgressTime())); + AnimationUtils.animateView(itemProgressView, true, 500); + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + AnimationUtils.animateView(itemProgressView, false, 500); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/PlaylistItemHolder.java new file mode 100644 index 000000000..7ee994cf7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/PlaylistItemHolder.java @@ -0,0 +1,52 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; + +import java.text.DateFormat; + +public abstract class PlaylistItemHolder extends LocalItemHolder { + public final ImageView itemThumbnailView; + final TextView itemStreamCountView; + public final TextView itemTitleView; + public final TextView itemUploaderView; + + public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); + itemUploaderView = itemView.findViewById(R.id.itemUploaderView); + } + + public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { + this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateFormat dateFormat) { + itemView.setOnClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(localItem); + } + }); + + itemView.setLongClickable(true); + itemView.setOnLongClickListener(view -> { + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(localItem); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistGridItemHolder.java new file mode 100644 index 000000000..d1f247bab --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistGridItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.local.LocalItemBuilder; + +public class RemotePlaylistGridItemHolder extends RemotePlaylistItemHolder { + public RemotePlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistItemHolder.java new file mode 100644 index 000000000..bb09ff659 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/holder/RemotePlaylistItemHolder.java @@ -0,0 +1,53 @@ +package org.schabi.newpipelegacy.local.holder; + +import android.text.TextUtils; +import android.view.ViewGroup; + +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.local.LocalItemBuilder; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; + +import java.text.DateFormat; + +public class RemotePlaylistItemHolder extends PlaylistItemHolder { + public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, parent); + } + + RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, + final ViewGroup parent) { + super(infoItemBuilder, layoutId, parent); + } + + @Override + public void updateFromItem(final LocalItem localItem, + final HistoryRecordManager historyRecordManager, + final DateFormat dateFormat) { + if (!(localItem instanceof PlaylistRemoteEntity)) { + return; + } + final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; + + itemTitleView.setText(item.getName()); + itemStreamCountView.setText(Localization.localizeStreamCountMini( + itemStreamCountView.getContext(), item.getStreamCount())); + // Here is where the uploader name is set in the bookmarked playlists library + if (!TextUtils.isEmpty(item.getUploader())) { + itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), + NewPipe.getNameOfService(item.getServiceId()))); + } else { + itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId())); + } + + + itemBuilder.displayImage(item.getThumbnailUrl(), itemThumbnailView, + ImageDisplayConstants.DISPLAY_PLAYLIST_OPTIONS); + + super.updateFromItem(localItem, historyRecordManager, dateFormat); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistFragment.java new file mode 100644 index 000000000..a6d8e6a5f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistFragment.java @@ -0,0 +1,827 @@ +package org.schabi.newpipelegacy.local.playlist; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.os.Parcelable; +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.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.history.model.StreamHistoryEntry; +import org.schabi.newpipelegacy.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipelegacy.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.info_list.InfoItemDialog; +import org.schabi.newpipelegacy.local.BaseLocalListFragment; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.OnClickGesture; +import org.schabi.newpipelegacy.util.StreamDialogEntry; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import icepick.State; +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.Disposables; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.PublishSubject; + +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +public class LocalPlaylistFragment extends BaseLocalListFragment, Void> { + // Save the list 10 seconds after the last change occurred + private static final long SAVE_DEBOUNCE_MILLIS = 10000; + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; + + @State + protected Long playlistId; + @State + protected String name; + @State + Parcelable itemsListState; + + private View headerRootLayout; + private TextView headerTitleView; + private TextView headerStreamCount; + private View playlistControl; + private View headerPlayAllButton; + private View headerPopupButton; + private View headerBackgroundButton; + + private ItemTouchHelper itemTouchHelper; + + private LocalPlaylistManager playlistManager; + private Subscription databaseSubscription; + + private PublishSubject debouncedSaveSignal; + private CompositeDisposable disposables; + + /* Has the playlist been fully loaded from db */ + private AtomicBoolean isLoadingComplete; + /* Has the playlist been modified (e.g. items reordered or deleted) */ + private AtomicBoolean isModified; + /* Is the playlist currently being processed to remove watched videos */ + private boolean isRemovingWatched = false; + + public static LocalPlaylistFragment getInstance(final long playlistId, final String name) { + LocalPlaylistFragment instance = new LocalPlaylistFragment(); + instance.setInitialData(playlistId, name); + return instance; + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + debouncedSaveSignal = PublishSubject.create(); + + disposables = new CompositeDisposable(); + + isLoadingComplete = new AtomicBoolean(); + isModified = new AtomicBoolean(); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_playlist, container, false); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Views + /////////////////////////////////////////////////////////////////////////// + + @Override + public void setTitle(final String title) { + super.setTitle(title); + + if (headerTitleView != null) { + headerTitleView.setText(title); + } + } + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + setTitle(name); + } + + @Override + protected View getListHeader() { + headerRootLayout = activity.getLayoutInflater() + .inflate(R.layout.local_playlist_header, itemsList, false); + + headerTitleView = headerRootLayout.findViewById(R.id.playlist_title_view); + headerTitleView.setSelected(true); + + headerStreamCount = headerRootLayout.findViewById(R.id.playlist_stream_count); + + playlistControl = headerRootLayout.findViewById(R.id.playlist_control); + headerPlayAllButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_all_button); + headerPopupButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_popup_button); + headerBackgroundButton = headerRootLayout.findViewById(R.id.playlist_ctrl_play_bg_button); + + return headerRootLayout; + } + + @Override + protected void initListeners() { + super.initListeners(); + + headerTitleView.setOnClickListener(view -> createRenameDialog()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + itemListAdapter.setSelectedListener(new OnClickGesture() { + @Override + public void selected(final LocalItem selectedItem) { + if (selectedItem instanceof PlaylistStreamEntry) { + final PlaylistStreamEntry item = (PlaylistStreamEntry) selectedItem; + NavigationHelper.openVideoDetailFragment(getFragmentManager(), + item.getStreamEntity().getServiceId(), item.getStreamEntity().getUrl(), + item.getStreamEntity().getTitle()); + } + } + + @Override + public void held(final LocalItem selectedItem) { + if (selectedItem instanceof PlaylistStreamEntry) { + showStreamItemDialog((PlaylistStreamEntry) selectedItem); + } + } + + @Override + public void drag(final LocalItem selectedItem, + final RecyclerView.ViewHolder viewHolder) { + if (itemTouchHelper != null) { + itemTouchHelper.startDrag(viewHolder); + } + } + }); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Loading + /////////////////////////////////////////////////////////////////////////// + + @Override + public void showLoading() { + super.showLoading(); + if (headerRootLayout != null) { + animateView(headerRootLayout, false, 200); + } + if (playlistControl != null) { + animateView(playlistControl, false, 200); + } + } + + @Override + public void hideLoading() { + super.hideLoading(); + if (headerRootLayout != null) { + animateView(headerRootLayout, true, 200); + } + if (playlistControl != null) { + animateView(playlistControl, true, 200); + } + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + if (disposables != null) { + disposables.clear(); + } + disposables.add(getDebouncedSaver()); + + isLoadingComplete.set(false); + isModified.set(false); + + playlistManager.getPlaylistStreams(playlistId) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistObserver()); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Destruction + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onPause() { + super.onPause(); + itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); + + // Save on exit + saveImmediate(); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]"); + } + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.menu_local_playlist, menu); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + if (itemListAdapter != null) { + itemListAdapter.unsetSelectedListener(); + } + if (headerBackgroundButton != null) { + headerBackgroundButton.setOnClickListener(null); + } + if (headerPlayAllButton != null) { + headerPlayAllButton.setOnClickListener(null); + } + if (headerPopupButton != null) { + headerPopupButton.setOnClickListener(null); + } + + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + if (disposables != null) { + disposables.clear(); + } + + databaseSubscription = null; + itemTouchHelper = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (debouncedSaveSignal != null) { + debouncedSaveSignal.onComplete(); + } + if (disposables != null) { + disposables.dispose(); + } + + debouncedSaveSignal = null; + playlistManager = null; + disposables = null; + + isLoadingComplete = null; + isModified = null; + } + + /////////////////////////////////////////////////////////////////////////// + // Playlist Stream Loader + /////////////////////////////////////////////////////////////////////////// + + private Subscriber> getPlaylistObserver() { + return new Subscriber>() { + @Override + public void onSubscribe(final Subscription s) { + showLoading(); + isLoadingComplete.set(false); + + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + databaseSubscription = s; + databaseSubscription.request(1); + } + + @Override + public void onNext(final List streams) { + // Skip handling the result after it has been modified + if (isModified == null || !isModified.get()) { + handleResult(streams); + isLoadingComplete.set(true); + } + + if (databaseSubscription != null) { + databaseSubscription.request(1); + } + } + + @Override + public void onError(final Throwable exception) { + LocalPlaylistFragment.this.onError(exception); + } + + @Override + public void onComplete() { } + }; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.menu_item_remove_watched: + if (!isRemovingWatched) { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.remove_watched_popup_warning) + .setTitle(R.string.remove_watched_popup_title) + .setPositiveButton(R.string.yes, + (DialogInterface d, int id) -> removeWatchedStreams(false)) + .setNeutralButton( + R.string.remove_watched_popup_yes_and_partially_watched_videos, + (DialogInterface d, int id) -> removeWatchedStreams(true)) + .setNegativeButton(R.string.cancel, + (DialogInterface d, int id) -> d.cancel()) + .create() + .show(); + } + break; + default: + return super.onOptionsItemSelected(item); + } + return true; + } + + public void removeWatchedStreams(final boolean removePartiallyWatched) { + if (isRemovingWatched) { + return; + } + isRemovingWatched = true; + showLoading(); + + disposables.add(playlistManager.getPlaylistStreams(playlistId) + .subscribeOn(Schedulers.io()) + .map((List playlist) -> { + // Playlist data + final Iterator playlistIter = playlist.iterator(); + + // History data + final HistoryRecordManager recordManager + = new HistoryRecordManager(getContext()); + final Iterator historyIter = recordManager + .getStreamHistorySortedById().blockingFirst().iterator(); + + // Remove Watched, Functionality data + final List notWatchedItems = new ArrayList<>(); + boolean thumbnailVideoRemoved = false; + + // already sorted by ^ getStreamHistorySortedById(), binary search can be used + final ArrayList historyStreamIds = new ArrayList<>(); + while (historyIter.hasNext()) { + historyStreamIds.add(historyIter.next().getStreamId()); + } + + if (removePartiallyWatched) { + while (playlistIter.hasNext()) { + final PlaylistStreamEntry playlistItem = playlistIter.next(); + int indexInHistory = Collections.binarySearch(historyStreamIds, + playlistItem.getStreamId()); + + if (indexInHistory < 0) { + notWatchedItems.add(playlistItem); + } else if (!thumbnailVideoRemoved + && playlistManager.getPlaylistThumbnail(playlistId) + .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { + thumbnailVideoRemoved = true; + } + } + } else { + final Iterator streamStatesIter = recordManager + .loadLocalStreamStateBatch(playlist).blockingGet().iterator(); + + while (playlistIter.hasNext()) { + PlaylistStreamEntry playlistItem = playlistIter.next(); + final int indexInHistory = Collections.binarySearch(historyStreamIds, + playlistItem.getStreamId()); + + final boolean hasState = streamStatesIter.next() != null; + if (indexInHistory < 0 || hasState) { + notWatchedItems.add(playlistItem); + } else if (!thumbnailVideoRemoved + && playlistManager.getPlaylistThumbnail(playlistId) + .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { + thumbnailVideoRemoved = true; + } + } + } + + return Flowable.just(notWatchedItems, thumbnailVideoRemoved); + }) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(flow -> { + final List notWatchedItems = + (List) flow.blockingFirst(); + final boolean thumbnailVideoRemoved = (Boolean) flow.blockingLast(); + + itemListAdapter.clearStreamItemList(); + itemListAdapter.addItems(notWatchedItems); + saveChanges(); + + + if (thumbnailVideoRemoved) { + updateThumbnailUrl(); + } + + final long videoCount = itemListAdapter.getItemsList().size(); + setVideoCount(videoCount); + if (videoCount == 0) { + showEmptyState(); + } + + hideLoading(); + isRemovingWatched = false; + }, this::onError)); + } + + @Override + public void handleResult(@NonNull final List result) { + super.handleResult(result); + if (itemListAdapter == null) { + return; + } + + itemListAdapter.clearStreamItemList(); + + if (result.isEmpty()) { + showEmptyState(); + return; + } + + itemListAdapter.addItems(result); + if (itemsListState != null) { + itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); + itemsListState = null; + } + setVideoCount(itemListAdapter.getItemsList().size()); + + headerPlayAllButton.setOnClickListener(view -> + NavigationHelper.playOnMainPlayer(activity, getPlayQueue(), false)); + headerPopupButton.setOnClickListener(view -> + NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); + headerBackgroundButton.setOnClickListener(view -> + NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); + + headerPopupButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPopupPlayer(activity, getPlayQueue(), true); + return true; + }); + + headerBackgroundButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnBackgroundPlayer(activity, getPlayQueue(), true); + return true; + }); + + hideLoading(); + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected void resetFragment() { + super.resetFragment(); + if (databaseSubscription != null) { + databaseSubscription.cancel(); + } + } + + @Override + protected boolean onError(final Throwable exception) { + if (super.onError(exception)) { + return true; + } + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, + "none", "Local Playlist", R.string.general_error); + return true; + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Metadata/Streams Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + private void createRenameDialog() { + if (playlistId == null || name == null || getContext() == null) { + return; + } + + final View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null); + EditText nameEdit = dialogView.findViewById(R.id.playlist_name); + nameEdit.setText(name); + nameEdit.setSelection(nameEdit.getText().length()); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()) + .setTitle(R.string.rename_playlist) + .setView(dialogView) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.rename, (dialogInterface, i) -> { + changePlaylistName(nameEdit.getText().toString()); + }); + + dialogBuilder.show(); + } + + private void changePlaylistName(final String title) { + if (playlistManager == null) { + return; + } + + this.name = title; + setTitle(title); + + if (DEBUG) { + Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + + "with new title=[" + title + "] items"); + } + + final Disposable disposable = playlistManager.renamePlaylist(playlistId, title) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(longs -> { /*Do nothing on success*/ }, this::onError); + disposables.add(disposable); + } + + private void changeThumbnailUrl(final String thumbnailUrl) { + if (playlistManager == null) { + return; + } + + final Toast successToast = Toast.makeText(getActivity(), + R.string.playlist_thumbnail_change_success, + Toast.LENGTH_SHORT); + + if (DEBUG) { + Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + + "with new thumbnail url=[" + thumbnailUrl + "]"); + } + + final Disposable disposable = playlistManager + .changePlaylistThumbnail(playlistId, thumbnailUrl) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignore -> successToast.show(), this::onError); + disposables.add(disposable); + } + + private void updateThumbnailUrl() { + String newThumbnailUrl; + + if (!itemListAdapter.getItemsList().isEmpty()) { + newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)) + .getStreamEntity().getThumbnailUrl(); + } else { + newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist; + } + + changeThumbnailUrl(newThumbnailUrl); + } + + private void deleteItem(final PlaylistStreamEntry item) { + if (itemListAdapter == null) { + return; + } + + itemListAdapter.removeItem(item); + if (playlistManager.getPlaylistThumbnail(playlistId) + .equals(item.getStreamEntity().getThumbnailUrl())) { + updateThumbnailUrl(); + } + + setVideoCount(itemListAdapter.getItemsList().size()); + saveChanges(); + } + + private void saveChanges() { + if (isModified == null || debouncedSaveSignal == null) { + return; + } + + isModified.set(true); + debouncedSaveSignal.onNext(System.currentTimeMillis()); + } + + private Disposable getDebouncedSaver() { + if (debouncedSaveSignal == null) { + return Disposables.empty(); + } + + return debouncedSaveSignal + .debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignored -> saveImmediate(), this::onError); + } + + private void saveImmediate() { + if (playlistManager == null || itemListAdapter == null) { + return; + } + + // List must be loaded and modified in order to save + if (isLoadingComplete == null || isModified == null + || !isLoadingComplete.get() || !isModified.get()) { + Log.w(TAG, "Attempting to save playlist when local playlist " + + "is not loaded or not modified: playlist id=[" + playlistId + "]"); + return; + } + + final List items = itemListAdapter.getItemsList(); + List streamIds = new ArrayList<>(items.size()); + for (final LocalItem item : items) { + if (item instanceof PlaylistStreamEntry) { + streamIds.add(((PlaylistStreamEntry) item).getStreamId()); + } + } + + if (DEBUG) { + Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + + "with [" + streamIds.size() + "] items"); + } + + final Disposable disposable = playlistManager.updateJoin(playlistId, streamIds) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + () -> { + if (isModified != null) { + isModified.set(false); + } + }, + this::onError + ); + disposables.add(disposable); + } + + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; + if (isGridLayout()) { + directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; + } + return new ItemTouchHelper.SimpleCallback(directions, + ItemTouchHelper.ACTION_STATE_IDLE) { + @Override + public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + final int viewSize, + final int viewSizeOutOfBounds, + final int totalSize, + final long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, + viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(final RecyclerView recyclerView, + final RecyclerView.ViewHolder source, + final RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType() + || itemListAdapter == null) { + return false; + } + + final int sourceIndex = source.getAdapterPosition(); + final int targetIndex = target.getAdapterPosition(); + final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); + if (isSwapped) { + saveChanges(); + } + return isSwapped; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return false; + } + + @Override + public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) { + return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); + } + + protected void showStreamItemDialog(final PlaylistStreamEntry item) { + final Context context = getContext(); + final Activity activity = getActivity(); + if (context == null || context.getResources() == null || activity == null) { + return; + } + final StreamInfoItem infoItem = item.toStreamInfoItem(); + + if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.set_as_playlist_thumbnail, + StreamDialogEntry.delete, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + } else { + StreamDialogEntry.setEnabledEntries( + StreamDialogEntry.enqueue_on_background, + StreamDialogEntry.enqueue_on_popup, + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.set_as_playlist_thumbnail, + StreamDialogEntry.delete, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share); + + StreamDialogEntry.start_here_on_popup.setCustomAction( + (fragment, infoItemDuplicate) -> NavigationHelper. + playOnPopupPlayer(context, getPlayQueueStartingAt(item), true)); + } + + StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) -> + NavigationHelper.playOnBackgroundPlayer(context, + getPlayQueueStartingAt(item), true)); + StreamDialogEntry.set_as_playlist_thumbnail.setCustomAction( + (fragment, infoItemDuplicate) -> + changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())); + StreamDialogEntry.delete.setCustomAction((fragment, infoItemDuplicate) -> + deleteItem(item)); + + new InfoItemDialog(activity, infoItem, StreamDialogEntry.getCommands(context), + (dialog, which) -> StreamDialogEntry.clickOn(which, this, infoItem)).show(); + } + + private void setInitialData(final long pid, final String title) { + this.playlistId = pid; + this.name = !TextUtils.isEmpty(title) ? title : ""; + } + + private void setVideoCount(final long count) { + if (activity != null && headerStreamCount != null) { + headerStreamCount.setText(Localization.localizeStreamCount(activity, count)); + } + } + + private PlayQueue getPlayQueue() { + return getPlayQueue(0); + } + + private PlayQueue getPlayQueue(final int index) { + if (itemListAdapter == null) { + return new SinglePlayQueue(Collections.emptyList(), 0); + } + + final List infoItems = itemListAdapter.getItemsList(); + List streamInfoItems = new ArrayList<>(infoItems.size()); + for (final LocalItem item : infoItems) { + if (item instanceof PlaylistStreamEntry) { + streamInfoItems.add(((PlaylistStreamEntry) item).toStreamInfoItem()); + } + } + return new SinglePlayQueue(streamInfoItems, index); + } +} + diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistManager.java new file mode 100644 index 000000000..4bf791506 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/LocalPlaylistManager.java @@ -0,0 +1,129 @@ +package org.schabi.newpipelegacy.local.playlist; + +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.database.AppDatabase; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistDAO; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistStreamDAO; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistEntity; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistStreamEntity; +import org.schabi.newpipelegacy.database.stream.dao.StreamDAO; +import org.schabi.newpipelegacy.database.stream.model.StreamEntity; + +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Completable; +import io.reactivex.Flowable; +import io.reactivex.Maybe; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class LocalPlaylistManager { + private final AppDatabase database; + private final StreamDAO streamTable; + private final PlaylistDAO playlistTable; + private final PlaylistStreamDAO playlistStreamTable; + + public LocalPlaylistManager(final AppDatabase db) { + database = db; + streamTable = db.streamDAO(); + playlistTable = db.playlistDAO(); + playlistStreamTable = db.playlistStreamDAO(); + } + + public Maybe> createPlaylist(final String name, final List streams) { + // Disallow creation of empty playlists + if (streams.isEmpty()) { + return Maybe.empty(); + } + final StreamEntity defaultStream = streams.get(0); + final PlaylistEntity newPlaylist = + new PlaylistEntity(name, defaultStream.getThumbnailUrl()); + + return Maybe.fromCallable(() -> database.runInTransaction(() -> + upsertStreams(playlistTable.insert(newPlaylist), streams, 0)) + ).subscribeOn(Schedulers.io()); + } + + public Maybe> appendToPlaylist(final long playlistId, + final List streams) { + return playlistStreamTable.getMaximumIndexOf(playlistId) + .firstElement() + .map(maxJoinIndex -> database.runInTransaction(() -> + upsertStreams(playlistId, streams, maxJoinIndex + 1)) + ).subscribeOn(Schedulers.io()); + } + + private List upsertStreams(final long playlistId, + final List streams, + final int indexOffset) { + + List joinEntities = new ArrayList<>(streams.size()); + final List streamIds = streamTable.upsertAll(streams); + for (int index = 0; index < streamIds.size(); index++) { + joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), + index + indexOffset)); + } + return playlistStreamTable.insertAll(joinEntities); + } + + public Completable updateJoin(final long playlistId, final List streamIds) { + List joinEntities = new ArrayList<>(streamIds.size()); + for (int i = 0; i < streamIds.size(); i++) { + joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i)); + } + + return Completable.fromRunnable(() -> database.runInTransaction(() -> { + playlistStreamTable.deleteBatch(playlistId); + playlistStreamTable.insertAll(joinEntities); + })).subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylists() { + return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylistStreams(final long playlistId) { + return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); + } + + public Single deletePlaylist(final long playlistId) { + return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId)) + .subscribeOn(Schedulers.io()); + } + + public Maybe renamePlaylist(final long playlistId, final String name) { + return modifyPlaylist(playlistId, name, null); + } + + public Maybe changePlaylistThumbnail(final long playlistId, + final String thumbnailUrl) { + return modifyPlaylist(playlistId, null, thumbnailUrl); + } + + public String getPlaylistThumbnail(final long playlistId) { + return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl(); + } + + private Maybe modifyPlaylist(final long playlistId, + @Nullable final String name, + @Nullable final String thumbnailUrl) { + return playlistTable.getPlaylist(playlistId) + .firstElement() + .filter(playlistEntities -> !playlistEntities.isEmpty()) + .map(playlistEntities -> { + PlaylistEntity playlist = playlistEntities.get(0); + if (name != null) { + playlist.setName(name); + } + if (thumbnailUrl != null) { + playlist.setThumbnailUrl(thumbnailUrl); + } + return playlistTable.update(playlist); + }).subscribeOn(Schedulers.io()); + } + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/RemotePlaylistManager.java new file mode 100644 index 000000000..c4fc9192a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/playlist/RemotePlaylistManager.java @@ -0,0 +1,50 @@ +package org.schabi.newpipelegacy.local.playlist; + +import org.schabi.newpipelegacy.database.AppDatabase; +import org.schabi.newpipelegacy.database.playlist.dao.PlaylistRemoteDAO; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; + +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class RemotePlaylistManager { + + private final PlaylistRemoteDAO playlistRemoteTable; + + public RemotePlaylistManager(final AppDatabase db) { + playlistRemoteTable = db.playlistRemoteDAO(); + } + + public Flowable> getPlaylists() { + return playlistRemoteTable.getAll().subscribeOn(Schedulers.io()); + } + + public Flowable> getPlaylist(final PlaylistInfo info) { + return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) + .subscribeOn(Schedulers.io()); + } + + public Single deletePlaylist(final long playlistId) { + return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId)) + .subscribeOn(Schedulers.io()); + } + + public Single onBookmark(final PlaylistInfo playlistInfo) { + return Single.fromCallable(() -> { + final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); + return playlistRemoteTable.upsert(playlist); + }).subscribeOn(Schedulers.io()); + } + + public Single onUpdate(final long playlistId, final PlaylistInfo playlistInfo) { + return Single.fromCallable(() -> { + PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); + playlist.setUid(playlistId); + return playlistRemoteTable.update(playlist); + }).subscribeOn(Schedulers.io()); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/FeedGroupIcon.kt new file mode 100644 index 000000000..0dc378a3a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/FeedGroupIcon.kt @@ -0,0 +1,63 @@ +package org.schabi.newpipelegacy.local.subscription + +import android.content.Context +import androidx.annotation.AttrRes +import androidx.annotation.DrawableRes +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.util.ThemeHelper + +enum class FeedGroupIcon( + /** + * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB). + */ + val id: Int, + + /** + * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes. + */ + @AttrRes val drawableResourceAttr: Int +) { + ALL(0, R.attr.ic_asterisk), + MUSIC(1, R.attr.ic_music_note), + EDUCATION(2, R.attr.ic_school), + FITNESS(3, R.attr.ic_fitness_center), + SPACE(4, R.attr.ic_telescope), + COMPUTER(5, R.attr.ic_computer), + GAMING(6, R.attr.ic_videogame_asset), + SPORTS(7, R.attr.ic_sports), + NEWS(8, R.attr.ic_megaphone), + FAVORITES(9, R.attr.ic_heart), + CAR(10, R.attr.ic_car), + MOTORCYCLE(11, R.attr.ic_motorcycle), + TREND(12, R.attr.ic_trending_up), + MOVIE(13, R.attr.ic_movie), + BACKUP(14, R.attr.ic_backup), + ART(15, R.attr.ic_palette), + PERSON(16, R.attr.ic_person), + PEOPLE(17, R.attr.ic_people), + MONEY(18, R.attr.ic_money), + KIDS(19, R.attr.ic_child_care), + FOOD(20, R.attr.ic_fastfood), + SMILE(21, R.attr.ic_smile), + EXPLORE(22, R.attr.ic_explore), + RESTAURANT(23, R.attr.ic_restaurant), + MIC(24, R.attr.ic_mic), + HEADSET(25, R.attr.ic_headset), + RADIO(26, R.attr.ic_radio), + SHOPPING_CART(27, R.attr.ic_shopping_cart), + WATCH_LATER(28, R.attr.ic_watch_later), + WORK(29, R.attr.ic_work), + HOT(30, R.attr.ic_kiosk_hot), + CHANNEL(31, R.attr.ic_channel), + BOOKMARK(32, R.attr.ic_bookmark), + PETS(33, R.attr.ic_pets), + WORLD(34, R.attr.ic_world), + STAR(35, R.attr.ic_stars), + SUN(36, R.attr.ic_sunny), + RSS(37, R.attr.ic_rss); + + @DrawableRes + fun getDrawableRes(context: Context): Int { + return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/ImportConfirmationDialog.java new file mode 100644 index 000000000..39b29f15a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/ImportConfirmationDialog.java @@ -0,0 +1,73 @@ +package org.schabi.newpipelegacy.local.subscription; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import icepick.Icepick; +import icepick.State; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class ImportConfirmationDialog extends DialogFragment { + @State + protected Intent resultServiceIntent; + + public static void show(@NonNull final Fragment fragment, + @NonNull final Intent resultServiceIntent) { + if (fragment.getFragmentManager() == null) { + return; + } + + final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); + confirmationDialog.setResultServiceIntent(resultServiceIntent); + confirmationDialog.show(fragment.getFragmentManager(), null); + } + + public void setResultServiceIntent(final Intent resultServiceIntent) { + this.resultServiceIntent = resultServiceIntent; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + assureCorrectAppLanguage(getContext()); + return new AlertDialog.Builder(getContext(), ThemeHelper.getDialogTheme(getContext())) + .setMessage(R.string.import_network_expensive_warning) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.finish, (dialogInterface, i) -> { + if (resultServiceIntent != null && getContext() != null) { + getContext().startService(resultServiceIntent); + } + dismiss(); + }) + .create(); + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (resultServiceIntent == null) { + throw new IllegalStateException("Result intent is null"); + } + + Icepick.restoreInstanceState(this, savedInstanceState); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + Icepick.saveInstanceState(this, outState); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionFragment.kt new file mode 100644 index 000000000..74d8e5de9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionFragment.kt @@ -0,0 +1,446 @@ +package org.schabi.newpipelegacy.local.subscription + +import android.app.Activity +import android.app.AlertDialog +import android.content.BroadcastReceiver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.IntentFilter +import android.content.res.Configuration +import android.os.Bundle +import android.os.Environment +import android.os.Parcelable +import android.preference.PreferenceManager +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.lifecycle.ViewModelProviders +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.recyclerview.widget.GridLayoutManager +import com.nononsenseapps.filepicker.Utils +import com.xwray.groupie.Group +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.Item +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.State +import io.reactivex.disposables.CompositeDisposable +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.floor +import kotlin.math.max +import kotlinx.android.synthetic.main.dialog_title.view.itemAdditionalDetails +import kotlinx.android.synthetic.main.dialog_title.view.itemTitleView +import kotlinx.android.synthetic.main.fragment_subscription.items_list +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.fragments.BaseStateFragment +import org.schabi.newpipelegacy.local.subscription.SubscriptionViewModel.SubscriptionState +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialog +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupReorderDialog +import org.schabi.newpipelegacy.local.subscription.item.ChannelItem +import org.schabi.newpipelegacy.local.subscription.item.EmptyPlaceholderItem +import org.schabi.newpipelegacy.local.subscription.item.FeedGroupAddItem +import org.schabi.newpipelegacy.local.subscription.item.FeedGroupCardItem +import org.schabi.newpipelegacy.local.subscription.item.FeedGroupCarouselItem +import org.schabi.newpipelegacy.local.subscription.item.FeedImportExportItem +import org.schabi.newpipelegacy.local.subscription.item.HeaderWithMenuItem +import org.schabi.newpipelegacy.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsExportService +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.KEY_MODE +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.KEY_VALUE +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE +import org.schabi.newpipelegacy.report.UserAction +import org.schabi.newpipelegacy.util.AnimationUtils.animateView +import org.schabi.newpipelegacy.util.FilePickerActivityHelper +import org.schabi.newpipelegacy.util.NavigationHelper +import org.schabi.newpipelegacy.util.OnClickGesture +import org.schabi.newpipelegacy.util.ShareUtils +import org.schabi.newpipelegacy.util.ThemeHelper + +class SubscriptionFragment : BaseStateFragment() { + private lateinit var viewModel: SubscriptionViewModel + private lateinit var subscriptionManager: SubscriptionManager + private val disposables: CompositeDisposable = CompositeDisposable() + + private var subscriptionBroadcastReceiver: BroadcastReceiver? = null + + private val groupAdapter = GroupAdapter() + private val feedGroupsSection = Section() + private var feedGroupsCarousel: FeedGroupCarouselItem? = null + private lateinit var importExportItem: FeedImportExportItem + private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem + private val subscriptionsSection = Section() + + @State + @JvmField + var itemsListState: Parcelable? = null + @State + @JvmField + var feedGroupsListState: Parcelable? = null + @State + @JvmField + var importExportItemExpandedState: Boolean? = null + + init { + setHasOptionsMenu(true) + } + + // ///////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + // ///////////////////////////////////////////////////////////////////////// + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupInitialLayout() + } + + override fun setUserVisibleHint(isVisibleToUser: Boolean) { + super.setUserVisibleHint(isVisibleToUser) + if (activity != null && isVisibleToUser) { + setTitle(activity.getString(R.string.tab_subscriptions)) + } + } + + override fun onAttach(context: Context) { + super.onAttach(context) + subscriptionManager = SubscriptionManager(requireContext()) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_subscription, container, false) + } + + override fun onResume() { + super.onResume() + setupBroadcastReceiver() + } + + override fun onPause() { + super.onPause() + itemsListState = items_list.layoutManager?.onSaveInstanceState() + feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() + importExportItemExpandedState = importExportItem.isExpanded + + if (subscriptionBroadcastReceiver != null && activity != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + } + + override fun onDestroy() { + super.onDestroy() + disposables.dispose() + } + + // //////////////////////////////////////////////////////////////////////// + // Menu + // //////////////////////////////////////////////////////////////////////// + + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + + val supportActionBar = activity.supportActionBar + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true) + setTitle(getString(R.string.tab_subscriptions)) + } + } + + private fun setupBroadcastReceiver() { + if (activity == null) return + + if (subscriptionBroadcastReceiver != null) { + LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) + } + + val filters = IntentFilter() + filters.addAction(EXPORT_COMPLETE_ACTION) + filters.addAction(IMPORT_COMPLETE_ACTION) + subscriptionBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + items_list?.post { + importExportItem.isExpanded = false + importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) + } + } + } + + LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters) + } + + private fun onImportFromServiceSelected(serviceId: Int) { + val fragmentManager = fm + NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId) + } + + private fun onImportPreviousSelected() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + } + + private fun onExportSelected() { + val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) + val exportName = "newpipe_subscriptions_$date.json" + val exportFile = File(Environment.getExternalStorageDirectory(), exportName) + + startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE) + } + + private fun openReorderDialog() { + FeedGroupReorderDialog().show(requireFragmentManager(), null) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (data != null && data.data != null && resultCode == Activity.RESULT_OK) { + if (requestCode == REQUEST_EXPORT_CODE) { + val exportFile = Utils.getFileForUri(data.data!!) + if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) { + Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show() + } else { + activity.startService(Intent(activity, SubscriptionsExportService::class.java) + .putExtra(KEY_FILE_PATH, exportFile.absolutePath)) + } + } else if (requestCode == REQUEST_IMPORT_CODE) { + val path = Utils.getFileForUri(data.data!!).absolutePath + ImportConfirmationDialog.show(this, Intent(activity, SubscriptionsImportService::class.java) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, path)) + } + } + } + + // //////////////////////////////////////////////////////////////////////// + // Fragment Views + // //////////////////////////////////////////////////////////////////////// + + private fun setupInitialLayout() { + Section().apply { + val carouselAdapter = GroupAdapter() + + carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS)) + carouselAdapter.add(feedGroupsSection) + carouselAdapter.add(FeedGroupAddItem()) + + carouselAdapter.setOnItemClickListener { item, _ -> + listenerFeedGroups.selected(item) + } + carouselAdapter.setOnItemLongClickListener { item, _ -> + if (item is FeedGroupCardItem) { + if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) { + return@setOnItemLongClickListener false + } + } + listenerFeedGroups.held(item) + return@setOnItemLongClickListener true + } + + feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter) + feedGroupsSortMenuItem = HeaderWithMenuItem( + getString(R.string.feed_groups_header_title), + ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort), + menuItemOnClickListener = ::openReorderDialog + ) + add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) + + groupAdapter.add(this) + } + + subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) + subscriptionsSection.setHideWhenEmpty(true) + + importExportItem = FeedImportExportItem( + { onImportPreviousSelected() }, + { onImportFromServiceSelected(it) }, + { onExportSelected() }, + importExportItemExpandedState ?: false) + groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection))) + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + val shouldUseGridLayout = shouldUseGridLayout() + groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1 + items_list.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { + spanSizeLookup = groupAdapter.spanSizeLookup + } + items_list.adapter = groupAdapter + + viewModel = ViewModelProviders.of(this).get(SubscriptionViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleResult) }) + viewModel.feedGroupsLiveData.observe(viewLifecycleOwner, androidx.lifecycle.Observer { it?.let(this::handleFeedGroups) }) + } + + private fun showLongTapDialog(selectedItem: ChannelInfoItem) { + val commands = arrayOf( + getString(R.string.share), + getString(R.string.unsubscribe) + ) + + val actions = DialogInterface.OnClickListener { _, i -> + when (i) { + 0 -> ShareUtils.shareUrl(requireContext(), selectedItem.name, selectedItem.url) + 1 -> deleteChannel(selectedItem) + } + } + + val bannerView = View.inflate(requireContext(), R.layout.dialog_title, null) + bannerView.isSelected = true + bannerView.itemTitleView.text = selectedItem.name + bannerView.itemAdditionalDetails.visibility = View.GONE + + AlertDialog.Builder(requireContext()) + .setCustomTitle(bannerView) + .setItems(commands, actions) + .create() + .show() + } + + private fun deleteChannel(selectedItem: ChannelInfoItem) { + disposables.add(subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { + Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() + }) + } + + override fun doInitialLoadLogic() = Unit + override fun startLoading(forceLoad: Boolean) = Unit + + private val listenerFeedGroups = object : OnClickGesture>() { + override fun selected(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name) + is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null) + } + } + + override fun held(selectedItem: Item<*>?) { + when (selectedItem) { + is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null) + } + } + } + + private val listenerChannelItem = object : OnClickGesture() { + override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(fm, + selectedItem.serviceId, selectedItem.url, selectedItem.name) + + override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem) + } + + override fun handleResult(result: SubscriptionState) { + super.handleResult(result) + + val shouldUseGridLayout = shouldUseGridLayout() + when (result) { + is SubscriptionState.LoadedState -> { + result.subscriptions.forEach { + if (it is ChannelItem) { + it.gesturesListener = listenerChannelItem + it.itemVersion = when { + shouldUseGridLayout -> ChannelItem.ItemVersion.GRID + else -> ChannelItem.ItemVersion.MINI + } + } + } + + subscriptionsSection.update(result.subscriptions) + subscriptionsSection.setHideWhenEmpty(false) + + if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) { + items_list.post { + importExportItem.isExpanded = true + importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) + } + } + + if (itemsListState != null) { + items_list.layoutManager?.onRestoreInstanceState(itemsListState) + itemsListState = null + } + } + is SubscriptionState.ErrorState -> { + result.error?.let { onError(result.error) } + } + } + } + + private fun handleFeedGroups(groups: List) { + feedGroupsSection.update(groups) + + if (feedGroupsListState != null) { + feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState) + feedGroupsListState = null + } + + feedGroupsSortMenuItem.showMenuItem = groups.size > 1 + items_list.post { feedGroupsSortMenuItem.notifyChanged(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM) } + } + + // ///////////////////////////////////////////////////////////////////////// + // Contract + // ///////////////////////////////////////////////////////////////////////// + + override fun showLoading() { + super.showLoading() + animateView(items_list, false, 100) + } + + override fun hideLoading() { + super.hideLoading() + animateView(items_list, true, 200) + } + + // ///////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + // ///////////////////////////////////////////////////////////////////////// + + override fun onError(exception: Throwable): Boolean { + if (super.onError(exception)) return true + + onUnrecoverableError(exception, UserAction.SOMETHING_ELSE, "none", "Subscriptions", R.string.general_error) + return true + } + + // ///////////////////////////////////////////////////////////////////////// + // Grid Mode + // ///////////////////////////////////////////////////////////////////////// + + // TODO: Move these out of this class, as it can be reused + + private fun shouldUseGridLayout(): Boolean { + val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)) + + return when (listMode) { + getString(R.string.list_view_mode_auto_key) -> { + val configuration = resources.configuration + + (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && + configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)) + } + getString(R.string.list_view_mode_grid_key) -> true + else -> false + } + } + + private fun getGridSpanCount(): Int { + val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width) + return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) + } + + companion object { + private const val REQUEST_EXPORT_CODE = 666 + private const val REQUEST_IMPORT_CODE = 667 + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionManager.kt new file mode 100644 index 000000000..643a521c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionManager.kt @@ -0,0 +1,95 @@ +package org.schabi.newpipelegacy.local.subscription + +import android.content.Context +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.feed.FeedInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipelegacy.NewPipeDatabase +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.database.subscription.SubscriptionDAO +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity +import org.schabi.newpipelegacy.local.feed.FeedDatabaseManager + +class SubscriptionManager(context: Context) { + private val database = NewPipeDatabase.getInstance(context) + private val subscriptionTable = database.subscriptionDAO() + private val feedDatabaseManager = FeedDatabaseManager(context) + + fun subscriptionTable(): SubscriptionDAO = subscriptionTable + fun subscriptions() = subscriptionTable.all + + fun getSubscriptions( + currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID, + filterQuery: String = "", + showOnlyUngrouped: Boolean = false + ): Flowable> { + return when { + filterQuery.isNotEmpty() -> { + return if (showOnlyUngrouped) { + subscriptionTable.getSubscriptionsOnlyUngroupedFiltered( + currentGroupId, filterQuery) + } else { + subscriptionTable.getSubscriptionsFiltered(filterQuery) + } + } + showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId) + else -> subscriptionTable.all + } + } + + fun upsertAll(infoList: List): List { + val listEntities = subscriptionTable.upsertAll( + infoList.map { SubscriptionEntity.from(it) }) + + database.runInTransaction { + infoList.forEachIndexed { index, info -> + feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) + } + } + + return listEntities + } + + fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) + .flatMapCompletable { + Completable.fromRunnable { + it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + subscriptionTable.update(it) + feedDatabaseManager.upsertAll(it.uid, info.relatedItems) + } + } + + fun updateFromInfo(subscriptionId: Long, info: ListInfo) { + val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) + + if (info is FeedInfo) { + subscriptionEntity.name = info.name + } else if (info is ChannelInfo) { + subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) + } + + subscriptionTable.update(subscriptionEntity) + } + + fun deleteSubscription(serviceId: Int, url: String): Completable { + return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { + database.runInTransaction { + val subscriptionId = subscriptionTable.insert(subscriptionEntity) + feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) + } + } + + fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { + subscriptionTable.delete(subscriptionEntity) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionViewModel.kt new file mode 100644 index 000000000..3bf7ef918 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionViewModel.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipelegacy.local.subscription + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.xwray.groupie.Group +import io.reactivex.schedulers.Schedulers +import java.util.concurrent.TimeUnit +import org.schabi.newpipelegacy.local.feed.FeedDatabaseManager +import org.schabi.newpipelegacy.local.subscription.item.ChannelItem +import org.schabi.newpipelegacy.local.subscription.item.FeedGroupCardItem +import org.schabi.newpipelegacy.util.DEFAULT_THROTTLE_TIMEOUT + +class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) + private var subscriptionManager = SubscriptionManager(application) + + private val mutableStateLiveData = MutableLiveData() + private val mutableFeedGroupsLiveData = MutableLiveData>() + val stateLiveData: LiveData = mutableStateLiveData + val feedGroupsLiveData: LiveData> = mutableFeedGroupsLiveData + + private var feedGroupItemsDisposable = feedDatabaseManager.groups() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map(::FeedGroupCardItem) } + .subscribeOn(Schedulers.io()) + .subscribe( + { mutableFeedGroupsLiveData.postValue(it) }, + { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + private var stateItemsDisposable = subscriptionManager.subscriptions() + .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) + .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } } + .subscribeOn(Schedulers.io()) + .subscribe( + { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) }, + { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } + ) + + override fun onCleared() { + super.onCleared() + stateItemsDisposable.dispose() + feedGroupItemsDisposable.dispose() + } + + sealed class SubscriptionState { + data class LoadedState(val subscriptions: List) : SubscriptionState() + data class ErrorState(val error: Throwable? = null) : SubscriptionState() + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionsImportFragment.java new file mode 100644 index 000000000..bff85f6e4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/SubscriptionsImportFragment.java @@ -0,0 +1,222 @@ +package org.schabi.newpipelegacy.local.subscription; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.text.TextUtils; +import android.text.util.Linkify; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.core.text.util.LinkifyCompat; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipelegacy.BaseFragment; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.FilePickerActivityHelper; +import org.schabi.newpipelegacy.util.ServiceHelper; + +import java.util.Collections; +import java.util.List; + +import icepick.State; + +import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; +import static org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; +import static org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; +import static org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.KEY_MODE; +import static org.schabi.newpipelegacy.local.subscription.services.SubscriptionsImportService.KEY_VALUE; + +public class SubscriptionsImportFragment extends BaseFragment { + private static final int REQUEST_IMPORT_FILE_CODE = 666; + + @State + int currentServiceId = Constants.NO_SERVICE_ID; + + private List supportedSources; + private String relatedUrl; + + @StringRes + private int instructionsString; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private TextView infoTextView; + private EditText inputText; + private Button inputButton; + + public static SubscriptionsImportFragment getInstance(final int serviceId) { + SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); + instance.setInitialData(serviceId); + return instance; + } + + private void setInitialData(final int serviceId) { + this.currentServiceId = serviceId; + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setupServiceVariables(); + if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { + ErrorActivity.reportError(activity, Collections.emptyList(), null, null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, + NewPipe.getNameOfService(currentServiceId), + "Service don't support importing", R.string.general_error)); + activity.finish(); + } + } + + @Override + public void setUserVisibleHint(final boolean isVisibleToUser) { + super.setUserVisibleHint(isVisibleToUser); + if (isVisibleToUser) { + setTitle(getString(R.string.import_title)); + } + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_import, container, false); + } + + /*///////////////////////////////////////////////////////////////////////// + // Fragment Views + /////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + inputButton = rootView.findViewById(R.id.input_button); + inputText = rootView.findViewById(R.id.input_text); + + infoTextView = rootView.findViewById(R.id.info_text_view); + + // TODO: Support services that can import from more than one source + // (show the option to the user) + if (supportedSources.contains(CHANNEL_URL)) { + inputButton.setText(R.string.import_title); + inputText.setVisibility(View.VISIBLE); + inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId)); + } else { + inputButton.setText(R.string.import_file_title); + } + + if (instructionsString != 0) { + if (TextUtils.isEmpty(relatedUrl)) { + setInfoText(getString(instructionsString)); + } else { + setInfoText(getString(instructionsString, relatedUrl)); + } + } else { + setInfoText(""); + } + + ActionBar supportActionBar = activity.getSupportActionBar(); + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true); + setTitle(getString(R.string.import_title)); + } + } + + @Override + protected void initListeners() { + super.initListeners(); + inputButton.setOnClickListener(v -> onImportClicked()); + } + + private void onImportClicked() { + if (inputText.getVisibility() == View.VISIBLE) { + final String value = inputText.getText().toString(); + if (!value.isEmpty()) { + onImportUrl(value); + } + } else { + onImportFile(); + } + } + + public void onImportUrl(final String value) { + ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, CHANNEL_URL_MODE) + .putExtra(KEY_VALUE, value) + .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + } + + public void onImportFile() { + startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), + REQUEST_IMPORT_FILE_CODE); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (data == null) { + return; + } + + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE + && data.getData() != null) { + final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + ImportConfirmationDialog.show(this, + new Intent(activity, SubscriptionsImportService.class) + .putExtra(KEY_MODE, INPUT_STREAM_MODE).putExtra(KEY_VALUE, path) + .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); + } + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions + /////////////////////////////////////////////////////////////////////////// + + private void setupServiceVariables() { + if (currentServiceId != Constants.NO_SERVICE_ID) { + try { + final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId) + .getSubscriptionExtractor(); + supportedSources = extractor.getSupportedSources(); + relatedUrl = extractor.getRelatedUrl(); + instructionsString = ServiceHelper.getImportInstructions(currentServiceId); + return; + } catch (ExtractionException ignored) { + } + } + + supportedSources = Collections.emptyList(); + relatedUrl = null; + instructionsString = 0; + } + + private void setInfoText(final String infoString) { + infoTextView.setText(infoString); + LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/decoration/FeedGroupCarouselDecoration.kt new file mode 100644 index 000000000..1006ed8bf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/decoration/FeedGroupCarouselDecoration.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipelegacy.local.subscription.decoration + +import android.content.Context +import android.graphics.Rect +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipelegacy.R + +class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() { + + private val marginStartEnd: Int + private val marginTopBottom: Int + private val marginBetweenItems: Int + + init { + with(context.resources) { + marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin) + marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin) + marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin) + } + } + + override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) { + val childAdapterPosition = parent.getChildAdapterPosition(child) + val childAdapterCount = parent.adapter?.itemCount ?: 0 + + outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom) + + if (childAdapterPosition == 0) { + outRect.left = marginStartEnd + } else if (childAdapterPosition == childAdapterCount - 1) { + outRect.right = marginStartEnd + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialog.kt new file mode 100644 index 000000000..775599e6c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialog.kt @@ -0,0 +1,512 @@ +package org.schabi.newpipelegacy.local.subscription.dialog + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.text.Editable +import android.text.TextUtils +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.InputMethodManager +import android.widget.Toast +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.Section +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.Icepick +import icepick.State +import java.io.Serializable +import kotlin.collections.contains +import kotlinx.android.synthetic.main.dialog_feed_group_create.* +import kotlinx.android.synthetic.main.toolbar_search_layout.* +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.fragments.BackPressable +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialog.ScreenState.DeleteScreen +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialog.ScreenState.IconPickerScreen +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialog.ScreenState.InitialScreen +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent +import org.schabi.newpipelegacy.local.subscription.item.EmptyPlaceholderItem +import org.schabi.newpipelegacy.local.subscription.item.PickerIconItem +import org.schabi.newpipelegacy.local.subscription.item.PickerSubscriptionItem +import org.schabi.newpipelegacy.util.AndroidTvUtils +import org.schabi.newpipelegacy.util.ThemeHelper + +class FeedGroupDialog : DialogFragment(), BackPressable { + private lateinit var viewModel: FeedGroupDialogViewModel + private var groupId: Long = NO_GROUP_SELECTED + private var groupIcon: FeedGroupIcon? = null + private var groupSortOrder: Long = -1 + + sealed class ScreenState : Serializable { + object InitialScreen : ScreenState() + object IconPickerScreen : ScreenState() + object SubscriptionsPickerScreen : ScreenState() + object DeleteScreen : ScreenState() + } + + @State @JvmField var selectedIcon: FeedGroupIcon? = null + @State @JvmField var selectedSubscriptions: HashSet = HashSet() + @State @JvmField var wasSubscriptionSelectionChanged: Boolean = false + @State @JvmField var currentScreen: ScreenState = InitialScreen + + @State @JvmField var subscriptionsListState: Parcelable? = null + @State @JvmField var iconsListState: Parcelable? = null + @State @JvmField var wasSearchSubscriptionsVisible = false + @State @JvmField var subscriptionsCurrentSearchQuery = "" + @State @JvmField var subscriptionsShowOnlyUngrouped = false + + private val subscriptionMainSection = Section() + private val subscriptionEmptyFooter = Section() + private lateinit var subscriptionGroupAdapter: GroupAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.dialog_feed_group_create, container) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return object : Dialog(requireActivity(), theme) { + override fun onBackPressed() { + if (!this@FeedGroupDialog.onBackPressed()) { + super.onBackPressed() + } + } + } + } + + override fun onPause() { + super.onPause() + + wasSearchSubscriptionsVisible = isSearchVisible() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + iconsListState = icon_selector.layoutManager?.onSaveInstanceState() + subscriptionsListState = subscriptions_selector_list.layoutManager?.onSaveInstanceState() + + Icepick.saveInstanceState(this, outState) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProvider(this, + FeedGroupDialogViewModel.Factory(requireContext(), + groupId, subscriptionsCurrentSearchQuery, subscriptionsShowOnlyUngrouped) + ).get(FeedGroupDialogViewModel::class.java) + + viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) + viewModel.subscriptionsLiveData.observe(viewLifecycleOwner, Observer { + setupSubscriptionPicker(it.first, it.second) + }) + viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() + } + }) + + subscriptionGroupAdapter = GroupAdapter().apply { + add(subscriptionMainSection) + add(subscriptionEmptyFooter) + spanCount = 4 + } + subscriptions_selector_list.apply { + // Disable animations, too distracting. + itemAnimator = null + adapter = subscriptionGroupAdapter + layoutManager = GridLayoutManager(requireContext(), subscriptionGroupAdapter.spanCount, + RecyclerView.VERTICAL, false).apply { + spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup + } + } + + setupIconPicker() + setupListeners() + + showScreen(currentScreen) + + if (currentScreen == SubscriptionsPickerScreen && wasSearchSubscriptionsVisible) { + showSearch() + } else if (currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED) { + showKeyboard() + } + } + + override fun onDestroyView() { + super.onDestroyView() + subscriptions_selector_list?.adapter = null + icon_selector?.adapter = null + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Setup + //​//////////////////////////////////////////////////////////////////////// */ + + override fun onBackPressed(): Boolean { + if (currentScreen is SubscriptionsPickerScreen && isSearchVisible()) { + hideSearch() + return true + } else if (currentScreen !is InitialScreen) { + showScreen(InitialScreen) + return true + } + + return false + } + + private fun setupListeners() { + delete_button.setOnClickListener { showScreen(DeleteScreen) } + + cancel_button.setOnClickListener { + when (currentScreen) { + InitialScreen -> dismiss() + else -> showScreen(InitialScreen) + } + } + + group_name_input_container.error = null + group_name_input.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) {} + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + if (group_name_input_container.isErrorEnabled && !s.isNullOrBlank()) { + group_name_input_container.error = null + } + } + }) + + confirm_button.setOnClickListener { handlePositiveButton() } + + select_channel_button.setOnClickListener { + subscriptions_selector_list.scrollToPosition(0) + showScreen(SubscriptionsPickerScreen) + } + + val headerMenu = subscriptions_header_toolbar.menu + requireActivity().menuInflater.inflate(R.menu.menu_feed_group_dialog, headerMenu) + + headerMenu.findItem(R.id.action_search).setOnMenuItemClickListener { + showSearch() + true + } + + headerMenu.findItem(R.id.feed_group_toggle_show_only_ungrouped_subscriptions).apply { + isChecked = subscriptionsShowOnlyUngrouped + setOnMenuItemClickListener { + subscriptionsShowOnlyUngrouped = !subscriptionsShowOnlyUngrouped + it.isChecked = subscriptionsShowOnlyUngrouped + viewModel.toggleShowOnlyUngrouped(subscriptionsShowOnlyUngrouped) + true + } + } + + toolbar_search_clear.setOnClickListener { + if (TextUtils.isEmpty(toolbar_search_edit_text.text)) { + hideSearch() + return@setOnClickListener + } + resetSearch() + showKeyboardSearch() + } + + toolbar_search_edit_text.setOnClickListener { + if (AndroidTvUtils.isTv(context)) { + showKeyboardSearch() + } + } + + toolbar_search_edit_text.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) = Unit + override fun afterTextChanged(s: Editable) = Unit + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + val newQuery: String = toolbar_search_edit_text.text.toString() + subscriptionsCurrentSearchQuery = newQuery + viewModel.filterSubscriptionsBy(newQuery) + } + }) + + subscriptionGroupAdapter?.setOnItemClickListener(subscriptionPickerItemListener) + } + + private fun handlePositiveButton() = when { + currentScreen is InitialScreen -> handlePositiveButtonInitialScreen() + currentScreen is DeleteScreen -> viewModel.deleteGroup() + currentScreen is SubscriptionsPickerScreen && isSearchVisible() -> hideSearch() + else -> showScreen(InitialScreen) + } + + private fun handlePositiveButtonInitialScreen() { + val name = group_name_input.text.toString().trim() + val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL + + if (name.isBlank()) { + group_name_input_container.error = getString(R.string.feed_group_dialog_empty_name) + group_name_input.text = null + group_name_input.requestFocus() + return + } else { + group_name_input_container.error = null + } + + if (selectedSubscriptions.isEmpty()) { + Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() + return + } + + when (groupId) { + NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) + else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder) + } + } + + private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { + val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL + val name = feedGroupEntity?.name ?: "" + groupIcon = feedGroupEntity?.icon + groupSortOrder = feedGroupEntity?.sortOrder ?: -1 + + val feedGroupIcon = if (selectedIcon == null) icon else selectedIcon!! + icon_preview.setImageResource(feedGroupIcon.getDrawableRes(requireContext())) + + if (group_name_input.text.isNullOrBlank()) { + group_name_input.setText(name) + } + } + + private val subscriptionPickerItemListener = OnItemClickListener { item, view -> + if (item is PickerSubscriptionItem) { + val subscriptionId = item.subscriptionEntity.uid + wasSubscriptionSelectionChanged = true + + val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { + this.selectedSubscriptions.remove(subscriptionId) + false + } else { + this.selectedSubscriptions.add(subscriptionId) + true + } + + item.updateSelected(view, isSelected) + updateSubscriptionSelectedCount() + } + } + + private fun setupSubscriptionPicker( + subscriptions: List, + selectedSubscriptions: Set + ) { + if (!wasSubscriptionSelectionChanged) { + this.selectedSubscriptions.addAll(selectedSubscriptions) + } + + updateSubscriptionSelectedCount() + + if (subscriptions.isEmpty()) { + subscriptionEmptyFooter.clear() + subscriptionEmptyFooter.add(EmptyPlaceholderItem()) + } else { + subscriptionEmptyFooter.clear() + } + + subscriptions.forEach { + it.isSelected = this@FeedGroupDialog.selectedSubscriptions + .contains(it.subscriptionEntity.uid) + } + + subscriptionMainSection.update(subscriptions, false) + + if (subscriptionsListState != null) { + subscriptions_selector_list.layoutManager?.onRestoreInstanceState(subscriptionsListState) + subscriptionsListState = null + } else { + subscriptions_selector_list.scrollToPosition(0) + } + } + + private fun updateSubscriptionSelectedCount() { + val selectedCount = this.selectedSubscriptions.size + val selectedCountText = resources.getQuantityString( + R.plurals.feed_group_dialog_selection_count, + selectedCount, selectedCount) + selected_subscription_count_view.text = selectedCountText + subscriptions_header_info.text = selectedCountText + } + + private fun setupIconPicker() { + val groupAdapter = GroupAdapter() + groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) }) + + icon_selector.apply { + layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) + adapter = groupAdapter + + if (iconsListState != null) { + layoutManager?.onRestoreInstanceState(iconsListState) + iconsListState = null + } + } + + groupAdapter.setOnItemClickListener { item, _ -> + when (item) { + is PickerIconItem -> { + selectedIcon = item.icon + icon_preview.setImageResource(item.iconRes) + + showScreen(InitialScreen) + } + } + } + icon_preview.setOnClickListener { + icon_selector.scrollToPosition(0) + showScreen(IconPickerScreen) + } + + if (groupId == NO_GROUP_SELECTED) { + val icon = selectedIcon ?: FeedGroupIcon.ALL + icon_preview.setImageResource(icon.getDrawableRes(requireContext())) + } + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Screen Selector + //​//////////////////////////////////////////////////////////////////////// */ + + private fun showScreen(screen: ScreenState) { + currentScreen = screen + + options_root.onlyVisibleIn(InitialScreen) + icon_selector.onlyVisibleIn(IconPickerScreen) + subscriptions_selector.onlyVisibleIn(SubscriptionsPickerScreen) + delete_screen_message.onlyVisibleIn(DeleteScreen) + + separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen) + cancel_button.onlyVisibleIn(InitialScreen, DeleteScreen) + + confirm_button.setText(when { + currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create + else -> android.R.string.ok + }) + + delete_button.visibility = when { + currentScreen != InitialScreen -> View.GONE + groupId == NO_GROUP_SELECTED -> View.GONE + else -> View.VISIBLE + } + + hideKeyboard() + hideSearch() + } + + private fun View.onlyVisibleIn(vararg screens: ScreenState) { + visibility = when (currentScreen) { + in screens -> View.VISIBLE + else -> View.GONE + } + } + + /*/​////////////////////////////////////////////////////////////////////////// + // Utils + //​//////////////////////////////////////////////////////////////////////// */ + + private fun isSearchVisible() = subscriptions_header_search_container?.visibility == View.VISIBLE + + private fun resetSearch() { + toolbar_search_edit_text.setText("") + subscriptionsCurrentSearchQuery = "" + viewModel.clearSubscriptionsFilter() + } + + private fun hideSearch() { + resetSearch() + subscriptions_header_search_container.visibility = View.GONE + subscriptions_header_info_container.visibility = View.VISIBLE + subscriptions_header_toolbar.menu.findItem(R.id.action_search).isVisible = true + hideKeyboardSearch() + } + + private fun showSearch() { + subscriptions_header_search_container.visibility = View.VISIBLE + subscriptions_header_info_container.visibility = View.GONE + subscriptions_header_toolbar.menu.findItem(R.id.action_search).isVisible = false + showKeyboardSearch() + } + + private val inputMethodManager by lazy { + requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + } + + private fun showKeyboardSearch() { + if (toolbar_search_edit_text.requestFocus()) { + inputMethodManager.showSoftInput(toolbar_search_edit_text, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun hideKeyboardSearch() { + inputMethodManager.hideSoftInputFromWindow(toolbar_search_edit_text.windowToken, + InputMethodManager.RESULT_UNCHANGED_SHOWN) + toolbar_search_edit_text.clearFocus() + } + + private fun showKeyboard() { + if (group_name_input.requestFocus()) { + inputMethodManager.showSoftInput(group_name_input, InputMethodManager.SHOW_IMPLICIT) + } + } + + private fun hideKeyboard() { + inputMethodManager.hideSoftInputFromWindow(group_name_input.windowToken, + InputMethodManager.RESULT_UNCHANGED_SHOWN) + group_name_input.clearFocus() + } + + private fun disableInput() { + delete_button?.isEnabled = false + confirm_button?.isEnabled = false + cancel_button?.isEnabled = false + isCancelable = false + + hideKeyboard() + } + + companion object { + private const val KEY_GROUP_ID = "KEY_GROUP_ID" + private const val NO_GROUP_SELECTED = -1L + + fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { + val dialog = FeedGroupDialog() + + dialog.arguments = Bundle().apply { + putLong(KEY_GROUP_ID, groupId) + } + + return dialog + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialogViewModel.kt new file mode 100644 index 000000000..8c8a3e463 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupDialogViewModel.kt @@ -0,0 +1,127 @@ +package org.schabi.newpipelegacy.local.subscription.dialog + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.Completable +import io.reactivex.Flowable +import io.reactivex.disposables.Disposable +import io.reactivex.functions.BiFunction +import io.reactivex.processors.BehaviorProcessor +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.feed.FeedDatabaseManager +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon +import org.schabi.newpipelegacy.local.subscription.SubscriptionManager +import org.schabi.newpipelegacy.local.subscription.item.PickerSubscriptionItem + +class FeedGroupDialogViewModel( + applicationContext: Context, + private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + initialQuery: String = "", + initialShowOnlyUngrouped: Boolean = false +) : ViewModel() { + + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private var subscriptionManager = SubscriptionManager(applicationContext) + + private var filterSubscriptions = BehaviorProcessor.create() + private var toggleShowOnlyUngrouped = BehaviorProcessor.create() + + private var subscriptionsFlowable = Flowable + .combineLatest( + filterSubscriptions.startWith(initialQuery), + toggleShowOnlyUngrouped.startWith(initialShowOnlyUngrouped), + BiFunction { t1: String, t2: Boolean -> Filter(t1, t2) } + ) + .distinctUntilChanged() + .switchMap { filter -> + subscriptionManager.getSubscriptions(groupId, filter.query, filter.showOnlyUngrouped) + }.map { list -> list.map { PickerSubscriptionItem(it) } } + + private val mutableGroupLiveData = MutableLiveData() + private val mutableSubscriptionsLiveData = MutableLiveData, Set>>() + private val mutableDialogEventLiveData = MutableLiveData() + val groupLiveData: LiveData = mutableGroupLiveData + val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData + val dialogEventLiveData: LiveData = mutableDialogEventLiveData + + private var actionProcessingDisposable: Disposable? = null + + private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId) + .subscribeOn(Schedulers.io()) + .subscribe(mutableGroupLiveData::postValue) + + private var subscriptionsDisposable = Flowable + .combineLatest(subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId), + BiFunction { t1: List, t2: List -> t1 to t2.toSet() }) + .subscribeOn(Schedulers.io()) + .subscribe(mutableSubscriptionsLiveData::postValue) + + override fun onCleared() { + super.onCleared() + actionProcessingDisposable?.dispose() + subscriptionsDisposable.dispose() + feedGroupDisposable.dispose() + } + + fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { + doAction(feedDatabaseManager.createGroup(name, selectedIcon) + .flatMapCompletable { + feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) + }) + } + + fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) { + doAction(feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList()) + .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder)))) + } + + fun deleteGroup() { + doAction(feedDatabaseManager.deleteGroup(groupId)) + } + + private fun doAction(completable: Completable) { + if (actionProcessingDisposable == null) { + mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent + + actionProcessingDisposable = completable + .subscribeOn(Schedulers.io()) + .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } + } + } + + fun filterSubscriptionsBy(query: String) { + filterSubscriptions.onNext(query) + } + + fun clearSubscriptionsFilter() { + filterSubscriptions.onNext("") + } + + fun toggleShowOnlyUngrouped(showOnlyUngrouped: Boolean) { + toggleShowOnlyUngrouped.onNext(showOnlyUngrouped) + } + + sealed class DialogEvent { + object ProcessingEvent : DialogEvent() + object SuccessEvent : DialogEvent() + } + + data class Filter(val query: String, val showOnlyUngrouped: Boolean) + + class Factory( + private val context: Context, + private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + private val initialQuery: String = "", + private val initialShowOnlyUngrouped: Boolean = false + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedGroupDialogViewModel(context.applicationContext, + groupId, initialQuery, initialShowOnlyUngrouped) as T + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialog.kt new file mode 100644 index 000000000..dcc37eba7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialog.kt @@ -0,0 +1,115 @@ +package org.schabi.newpipelegacy.local.subscription.dialog + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.TouchCallback +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import icepick.Icepick +import icepick.State +import java.util.Collections +import kotlinx.android.synthetic.main.dialog_feed_group_reorder.confirm_button +import kotlinx.android.synthetic.main.dialog_feed_group_reorder.feed_groups_list +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.ProcessingEvent +import org.schabi.newpipelegacy.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.SuccessEvent +import org.schabi.newpipelegacy.local.subscription.item.FeedGroupReorderItem +import org.schabi.newpipelegacy.util.ThemeHelper + +class FeedGroupReorderDialog : DialogFragment() { + private lateinit var viewModel: FeedGroupReorderDialogViewModel + + @State + @JvmField + var groupOrderedIdList = ArrayList() + private val groupAdapter = GroupAdapter() + private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_feed_group_reorder, container) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel = ViewModelProviders.of(this).get(FeedGroupReorderDialogViewModel::class.java) + viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) + viewModel.dialogEventLiveData.observe(viewLifecycleOwner, Observer { + when (it) { + ProcessingEvent -> disableInput() + SuccessEvent -> dismiss() + } + }) + + feed_groups_list.layoutManager = LinearLayoutManager(requireContext()) + feed_groups_list.adapter = groupAdapter + itemTouchHelper.attachToRecyclerView(feed_groups_list) + + confirm_button.setOnClickListener { + viewModel.updateOrder(groupOrderedIdList) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + private fun handleGroups(list: List) { + val groupList: List + + if (groupOrderedIdList.isEmpty()) { + groupList = list + groupOrderedIdList.addAll(groupList.map { it.uid }) + } else { + groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) } + } + + groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) }) + } + + private fun disableInput() { + confirm_button?.isEnabled = false + isCancelable = false + } + + private fun getItemTouchCallback(): SimpleCallback { + return object : TouchCallback() { + + override fun onMove( + recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + val sourceIndex = source.adapterPosition + val targetIndex = target.adapterPosition + + groupAdapter.notifyItemMoved(sourceIndex, targetIndex) + Collections.swap(groupOrderedIdList, sourceIndex, targetIndex) + + return true + } + + override fun isLongPressDragEnabled(): Boolean = false + override fun isItemViewSwipeEnabled(): Boolean = false + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {} + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt new file mode 100644 index 000000000..6c99dabb2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipelegacy.local.subscription.dialog + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.reactivex.Completable +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.feed.FeedDatabaseManager + +class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) { + private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) + + private val mutableGroupsLiveData = MutableLiveData>() + private val mutableDialogEventLiveData = MutableLiveData() + val groupsLiveData: LiveData> = mutableGroupsLiveData + val dialogEventLiveData: LiveData = mutableDialogEventLiveData + + private var actionProcessingDisposable: Disposable? = null + + private var groupsDisposable = feedDatabaseManager.groups() + .limit(1) + .subscribeOn(Schedulers.io()) + .subscribe(mutableGroupsLiveData::postValue) + + override fun onCleared() { + super.onCleared() + actionProcessingDisposable?.dispose() + groupsDisposable.dispose() + } + + fun updateOrder(groupIdList: List) { + doAction(feedDatabaseManager.updateGroupsOrder(groupIdList)) + } + + private fun doAction(completable: Completable) { + if (actionProcessingDisposable == null) { + mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent + + actionProcessingDisposable = completable + .subscribeOn(Schedulers.io()) + .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } + } + } + + sealed class DialogEvent { + object ProcessingEvent : DialogEvent() + object SuccessEvent : DialogEvent() + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/ChannelItem.kt new file mode 100644 index 000000000..d9d4c21f5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/ChannelItem.kt @@ -0,0 +1,67 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.content.Context +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.list_channel_item.itemAdditionalDetails +import kotlinx.android.synthetic.main.list_channel_item.itemChannelDescriptionView +import kotlinx.android.synthetic.main.list_channel_item.itemThumbnailView +import kotlinx.android.synthetic.main.list_channel_item.itemTitleView +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.util.ImageDisplayConstants +import org.schabi.newpipelegacy.util.Localization +import org.schabi.newpipelegacy.util.OnClickGesture + +class ChannelItem( + private val infoItem: ChannelInfoItem, + private val subscriptionId: Long = -1L, + var itemVersion: ItemVersion = ItemVersion.NORMAL, + var gesturesListener: OnClickGesture? = null +) : Item() { + + override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId + + enum class ItemVersion { NORMAL, MINI, GRID } + + override fun getLayout(): Int = when (itemVersion) { + ItemVersion.NORMAL -> R.layout.list_channel_item + ItemVersion.MINI -> R.layout.list_channel_mini_item + ItemVersion.GRID -> R.layout.list_channel_grid_item + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.itemTitleView.text = infoItem.name + viewHolder.itemAdditionalDetails.text = getDetailLine(viewHolder.root.context) + if (itemVersion == ItemVersion.NORMAL) viewHolder.itemChannelDescriptionView.text = infoItem.description + + ImageLoader.getInstance().displayImage(infoItem.thumbnailUrl, viewHolder.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS) + + gesturesListener?.run { + viewHolder.containerView.setOnClickListener { selected(infoItem) } + viewHolder.containerView.setOnLongClickListener { held(infoItem); true } + } + } + + private fun getDetailLine(context: Context): String { + var details = if (infoItem.subscriberCount >= 0) { + Localization.shortSubscriberCount(context, infoItem.subscriberCount) + } else { + context.getString(R.string.subscribers_count_not_available) + } + + if (itemVersion == ItemVersion.NORMAL) { + if (infoItem.streamCount >= 0) { + val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount) + details = Localization.concatenateStrings(details, formattedVideoAmount) + } + } + return details + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/EmptyPlaceholderItem.kt new file mode 100644 index 000000000..115a0ac36 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/EmptyPlaceholderItem.kt @@ -0,0 +1,11 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import org.schabi.newpipelegacy.R + +class EmptyPlaceholderItem : Item() { + override fun getLayout(): Int = R.layout.list_empty_view + override fun bind(viewHolder: GroupieViewHolder, position: Int) {} + override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupAddItem.kt new file mode 100644 index 000000000..96bd8bbe9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupAddItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import org.schabi.newpipelegacy.R + +class FeedGroupAddItem : Item() { + override fun getLayout(): Int = R.layout.feed_group_add_new_item + override fun bind(viewHolder: GroupieViewHolder, position: Int) {} +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCardItem.kt new file mode 100644 index 000000000..689f47113 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCardItem.kt @@ -0,0 +1,31 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_group_card_item.icon +import kotlinx.android.synthetic.main.feed_group_card_item.title +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon + +data class FeedGroupCardItem( + val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + val name: String, + val icon: FeedGroupIcon +) : Item() { + constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) + + override fun getId(): Long { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> super.getId() + else -> groupId + } + } + + override fun getLayout(): Int = R.layout.feed_group_card_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.title.text = name + viewHolder.icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context)) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCarouselItem.kt new file mode 100644 index 000000000..54905706f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupCarouselItem.kt @@ -0,0 +1,57 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.content.Context +import android.os.Parcelable +import android.view.View +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_item_carousel.recycler_view +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.local.subscription.decoration.FeedGroupCarouselDecoration + +class FeedGroupCarouselItem(context: Context, private val carouselAdapter: GroupAdapter) : Item() { + private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context) + + private var linearLayoutManager: LinearLayoutManager? = null + private var listState: Parcelable? = null + + override fun getLayout() = R.layout.feed_item_carousel + + fun onSaveInstanceState(): Parcelable? { + listState = linearLayoutManager?.onSaveInstanceState() + return listState + } + + fun onRestoreInstanceState(state: Parcelable?) { + linearLayoutManager?.onRestoreInstanceState(state) + listState = state + } + + override fun createViewHolder(itemView: View): GroupieViewHolder { + val viewHolder = super.createViewHolder(itemView) + + linearLayoutManager = LinearLayoutManager(itemView.context, RecyclerView.HORIZONTAL, false) + + viewHolder.recycler_view.apply { + layoutManager = linearLayoutManager + adapter = carouselAdapter + addItemDecoration(feedGroupCarouselDecoration) + } + + return viewHolder + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.recycler_view.apply { adapter = carouselAdapter } + linearLayoutManager?.onRestoreInstanceState(listState) + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + + listState = linearLayoutManager?.onSaveInstanceState() + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupReorderItem.kt new file mode 100644 index 000000000..c4481dccf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedGroupReorderItem.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.view.MotionEvent +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.ItemTouchHelper.DOWN +import androidx.recyclerview.widget.ItemTouchHelper.UP +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_group_reorder_item.group_icon +import kotlinx.android.synthetic.main.feed_group_reorder_item.group_name +import kotlinx.android.synthetic.main.feed_group_reorder_item.handle +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon + +data class FeedGroupReorderItem( + val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + val name: String, + val icon: FeedGroupIcon, + val dragCallback: ItemTouchHelper +) : Item() { + constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper) : + this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback) + + override fun getId(): Long { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> super.getId() + else -> groupId + } + } + + override fun getLayout(): Int = R.layout.feed_group_reorder_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.group_name.text = name + viewHolder.group_icon.setImageResource(icon.getDrawableRes(viewHolder.containerView.context)) + viewHolder.handle.setOnTouchListener { _, event -> + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + dragCallback.startDrag(viewHolder) + return@setOnTouchListener true + } + + false + } + } + + override fun getDragDirs(): Int { + return UP or DOWN + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedImportExportItem.kt new file mode 100644 index 000000000..e55ed05a4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/FeedImportExportItem.kt @@ -0,0 +1,119 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.graphics.Color +import android.graphics.PorterDuff +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.feed_import_export_group.export_to_options +import kotlinx.android.synthetic.main.feed_import_export_group.import_export +import kotlinx.android.synthetic.main.feed_import_export_group.import_export_expand_icon +import kotlinx.android.synthetic.main.feed_import_export_group.import_export_options +import kotlinx.android.synthetic.main.feed_import_export_group.import_from_options +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.util.AnimationUtils +import org.schabi.newpipelegacy.util.ServiceHelper +import org.schabi.newpipelegacy.util.ThemeHelper +import org.schabi.newpipelegacy.views.CollapsibleView + +class FeedImportExportItem( + val onImportPreviousSelected: () -> Unit, + val onImportFromServiceSelected: (Int) -> Unit, + val onExportSelected: () -> Unit, + var isExpanded: Boolean = false +) : Item() { + companion object { + const val REFRESH_EXPANDED_STATUS = 123 + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(REFRESH_EXPANDED_STATUS)) { + viewHolder.import_export_options.apply { if (isExpanded) expand() else collapse() } + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun getLayout(): Int = R.layout.feed_import_export_group + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + if (viewHolder.import_from_options.childCount == 0) setupImportFromItems(viewHolder.import_from_options) + if (viewHolder.export_to_options.childCount == 0) setupExportToItems(viewHolder.export_to_options) + + expandIconListener?.let { viewHolder.import_export_options.removeListener(it) } + expandIconListener = CollapsibleView.StateListener { newState -> + AnimationUtils.animateRotation(viewHolder.import_export_expand_icon, + 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180) + } + + viewHolder.import_export_options.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED + viewHolder.import_export_expand_icon.rotation = if (isExpanded) 180F else 0F + viewHolder.import_export_options.ready() + + viewHolder.import_export_options.addListener(expandIconListener) + viewHolder.import_export.setOnClickListener { + viewHolder.import_export_options.switchState() + isExpanded = viewHolder.import_export_options.currentState == CollapsibleView.EXPANDED + } + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + expandIconListener?.let { viewHolder.import_export_options.removeListener(it) } + expandIconListener = null + } + + private var expandIconListener: CollapsibleView.StateListener? = null + + private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View { + val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null) + val titleView = itemRoot.findViewById(android.R.id.text1) + val iconView = itemRoot.findViewById(android.R.id.icon1) + + titleView.text = title + iconView.setImageResource(icon) + + container.addView(itemRoot) + return itemRoot + } + + private fun setupImportFromItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.previous_export), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder) + previousBackupItem.setOnClickListener { onImportPreviousSelected() } + + val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE + val services = listHolder.context.resources.getStringArray(R.array.service_list) + for (serviceName in services) { + try { + val service = NewPipe.getService(serviceName) + + val subscriptionExtractor = service.subscriptionExtractor ?: continue + + val supportedSources = subscriptionExtractor.supportedSources + if (supportedSources.isEmpty()) continue + + val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder) + val iconView = itemView.findViewById(android.R.id.icon1) + iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) + + itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) } + } catch (e: ExtractionException) { + throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e) + } + } + } + + private fun setupExportToItems(listHolder: ViewGroup) { + val previousBackupItem = addItemView(listHolder.context.getString(R.string.file), + ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), listHolder) + previousBackupItem.setOnClickListener { onExportSelected() } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderItem.kt new file mode 100644 index 000000000..a00f13340 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderItem.kt @@ -0,0 +1,19 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.view.View.OnClickListener +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.header_item.header_title +import org.schabi.newpipelegacy.R + +class HeaderItem(val title: String, private val onClickListener: (() -> Unit)? = null) : Item() { + + override fun getLayout(): Int = R.layout.header_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.header_title.text = title + + val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null + viewHolder.root.setOnClickListener(listener) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderWithMenuItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderWithMenuItem.kt new file mode 100644 index 000000000..5d01b5146 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/HeaderWithMenuItem.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.view.View.GONE +import android.view.View.OnClickListener +import android.view.View.VISIBLE +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.header_with_menu_item.header_menu_item +import kotlinx.android.synthetic.main.header_with_menu_item.header_title +import org.schabi.newpipelegacy.R + +class HeaderWithMenuItem( + val title: String, + @DrawableRes val itemIcon: Int = 0, + var showMenuItem: Boolean = true, + private val onClickListener: (() -> Unit)? = null, + private val menuItemOnClickListener: (() -> Unit)? = null +) : Item() { + companion object { + const val PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM = 1 + } + + override fun getLayout(): Int = R.layout.header_with_menu_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { + if (payloads.contains(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM)) { + updateMenuItemVisibility(viewHolder) + return + } + + super.bind(viewHolder, position, payloads) + } + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.header_title.text = title + viewHolder.header_menu_item.setImageResource(itemIcon) + + val listener: OnClickListener? = + onClickListener?.let { OnClickListener { onClickListener.invoke() } } + viewHolder.root.setOnClickListener(listener) + + val menuItemListener: OnClickListener? = + menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } } + viewHolder.header_menu_item.setOnClickListener(menuItemListener) + updateMenuItemVisibility(viewHolder) + } + + private fun updateMenuItemVisibility(viewHolder: GroupieViewHolder) { + viewHolder.header_menu_item.visibility = if (showMenuItem) VISIBLE else GONE + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerIconItem.kt new file mode 100644 index 000000000..470698f41 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerIconItem.kt @@ -0,0 +1,20 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.content.Context +import androidx.annotation.DrawableRes +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.picker_icon_item.icon_view +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.local.subscription.FeedGroupIcon + +class PickerIconItem(context: Context, val icon: FeedGroupIcon) : Item() { + @DrawableRes + val iconRes: Int = icon.getDrawableRes(context) + + override fun getLayout(): Int = R.layout.picker_icon_item + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + viewHolder.icon_view.setImageResource(iconRes) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerSubscriptionItem.kt new file mode 100644 index 000000000..1c2c857db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/item/PickerSubscriptionItem.kt @@ -0,0 +1,44 @@ +package org.schabi.newpipelegacy.local.subscription.item + +import android.view.View +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.kotlinandroidextensions.GroupieViewHolder +import com.xwray.groupie.kotlinandroidextensions.Item +import kotlinx.android.synthetic.main.picker_subscription_item.* +import kotlinx.android.synthetic.main.picker_subscription_item.view.* +import org.schabi.newpipelegacy.R +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity +import org.schabi.newpipelegacy.util.AnimationUtils +import org.schabi.newpipelegacy.util.AnimationUtils.animateView +import org.schabi.newpipelegacy.util.ImageDisplayConstants + +data class PickerSubscriptionItem( + val subscriptionEntity: SubscriptionEntity, + var isSelected: Boolean = false +) : Item() { + override fun getId(): Long = subscriptionEntity.uid + override fun getLayout(): Int = R.layout.picker_subscription_item + override fun getSpanSize(spanCount: Int, position: Int): Int = 1 + + override fun bind(viewHolder: GroupieViewHolder, position: Int) { + ImageLoader.getInstance().displayImage(subscriptionEntity.avatarUrl, + viewHolder.thumbnail_view, ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS) + + viewHolder.title_view.text = subscriptionEntity.name + viewHolder.selected_highlight.visibility = if (isSelected) View.VISIBLE else View.GONE + } + + override fun unbind(viewHolder: GroupieViewHolder) { + super.unbind(viewHolder) + + viewHolder.selected_highlight.animate().setListener(null).cancel() + viewHolder.selected_highlight.visibility = View.GONE + viewHolder.selected_highlight.alpha = 1F + } + + fun updateSelected(containerView: View, isSelected: Boolean) { + this.isSelected = isSelected + animateView(containerView.selected_highlight, + AnimationUtils.Type.LIGHT_SCALE_AND_ALPHA, isSelected, 150) + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/BaseImportExportService.java new file mode 100644 index 000000000..fd2d53c11 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/BaseImportExportService.java @@ -0,0 +1,235 @@ +/* + * Copyright 2018 Mauricio Colli + * BaseImportExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.subscription.services; + +import android.app.Service; +import android.content.Intent; +import android.os.Build; +import android.os.IBinder; +import android.text.TextUtils; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import org.reactivestreams.Publisher; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; +import org.schabi.newpipelegacy.local.subscription.SubscriptionManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.ExceptionUtils; + +import java.io.FileNotFoundException; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.functions.Function; +import io.reactivex.processors.PublishProcessor; + +public abstract class BaseImportExportService extends Service { + protected final String TAG = this.getClass().getSimpleName(); + + protected final CompositeDisposable disposables = new CompositeDisposable(); + protected final PublishProcessor notificationUpdater = PublishProcessor.create(); + + protected NotificationManagerCompat notificationManager; + protected NotificationCompat.Builder notificationBuilder; + protected SubscriptionManager subscriptionManager; + + private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; + + protected final AtomicInteger currentProgress = new AtomicInteger(-1); + protected final AtomicInteger maxProgress = new AtomicInteger(-1); + protected final ImportExportEventListener eventListener = new ImportExportEventListener() { + @Override + public void onSizeReceived(final int size) { + maxProgress.set(size); + currentProgress.set(0); + } + + @Override + public void onItemCompleted(final String itemName) { + currentProgress.incrementAndGet(); + notificationUpdater.onNext(itemName); + } + }; + + protected Toast toast; + + @Nullable + @Override + public IBinder onBind(final Intent intent) { + return null; + } + + @Override + public void onCreate() { + super.onCreate(); + subscriptionManager = new SubscriptionManager(this); + setupNotification(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposeAll(); + } + + protected void disposeAll() { + disposables.clear(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Notification Impl + //////////////////////////////////////////////////////////////////////////*/ + + protected abstract int getNotificationId(); + + @StringRes + public abstract int getTitle(); + + protected void setupNotification() { + notificationManager = NotificationManagerCompat.from(this); + notificationBuilder = createNotification(); + startForeground(getNotificationId(), notificationBuilder.build()); + + final Function, Publisher> throttleAfterFirstEmission = flow -> + flow.limit(1).concatWith(flow.skip(1) + .throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); + + disposables.add(notificationUpdater + .filter(s -> !s.isEmpty()) + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(this::updateNotification)); + } + + protected void updateNotification(final String text) { + notificationBuilder + .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); + + final String progressText = currentProgress + "/" + maxProgress; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!TextUtils.isEmpty(text)) { + notificationBuilder.setContentText(text + " (" + progressText + ")"); + } + } else { + notificationBuilder.setContentInfo(progressText); + notificationBuilder.setContentText(text); + } + + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected void stopService() { + postErrorResult(null, null); + } + + protected void stopAndReportError(@Nullable final Throwable error, final String request) { + stopService(); + + final ErrorActivity.ErrorInfo errorInfo = ErrorActivity.ErrorInfo + .make(UserAction.SUBSCRIPTION, "unknown", request, R.string.general_error); + ErrorActivity.reportError(this, error != null ? Collections.singletonList(error) + : Collections.emptyList(), null, null, errorInfo); + } + + protected void postErrorResult(final String title, final String text) { + disposeAll(); + stopForeground(true); + stopSelf(); + + if (title == null) { + return; + } + + final String textOrEmpty = text == null ? "" : text; + notificationBuilder = new NotificationCompat + .Builder(this, getString(R.string.notification_channel_id)) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(title) + .setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty)) + .setContentText(textOrEmpty); + notificationManager.notify(getNotificationId(), notificationBuilder.build()); + } + + protected NotificationCompat.Builder createNotification() { + return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setProgress(-1, -1, true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(getString(getTitle())); + } + + /*////////////////////////////////////////////////////////////////////////// + // Toast + //////////////////////////////////////////////////////////////////////////*/ + + protected void showToast(@StringRes final int message) { + showToast(getString(message)); + } + + protected void showToast(final String message) { + if (toast != null) { + toast.cancel(); + } + + toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); + toast.show(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + //////////////////////////////////////////////////////////////////////////*/ + + protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) { + String message = getErrorMessage(error); + + if (TextUtils.isEmpty(message)) { + final String errorClassName = error.getClass().getName(); + message = getString(R.string.error_occurred_detail, errorClassName); + } + + showToast(errorTitle); + postErrorResult(getString(errorTitle), message); + } + + protected String getErrorMessage(final Throwable error) { + String message = null; + if (error instanceof SubscriptionExtractor.InvalidSourceException) { + message = getString(R.string.invalid_source); + } else if (error instanceof FileNotFoundException) { + message = getString(R.string.invalid_file); + } else if (ExceptionUtils.isNetworkRelated(error)) { + message = getString(R.string.network_error); + } + return message; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportEventListener.java new file mode 100644 index 000000000..ed6ff44c8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportEventListener.java @@ -0,0 +1,17 @@ +package org.schabi.newpipelegacy.local.subscription.services; + +public interface ImportExportEventListener { + /** + * Called when the size has been resolved. + * + * @param size how many items there are to import/export + */ + void onSizeReceived(int size); + + /** + * Called everytime an item has been parsed/resolved. + * + * @param itemName the name of the subscription item + */ + void onItemCompleted(String itemName); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportJsonHelper.java new file mode 100644 index 000000000..c07b716c3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/ImportExportJsonHelper.java @@ -0,0 +1,158 @@ +/* + * Copyright 2018 Mauricio Colli + * ImportExportJsonHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.subscription.services; + +import androidx.annotation.Nullable; + +import com.grack.nanojson.JsonAppendableWriter; +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonSink; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; + +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * A JSON implementation capable of importing and exporting subscriptions, it has the advantage + * of being able to transfer subscriptions to any device. + */ +public final class ImportExportJsonHelper { + /*////////////////////////////////////////////////////////////////////////// + // Json implementation + //////////////////////////////////////////////////////////////////////////*/ + + private static final String JSON_APP_VERSION_KEY = "app_version"; + private static final String JSON_APP_VERSION_INT_KEY = "app_version_int"; + + private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions"; + + private static final String JSON_SERVICE_ID_KEY = "service_id"; + private static final String JSON_URL_KEY = "url"; + private static final String JSON_NAME_KEY = "name"; + + private ImportExportJsonHelper() { } + + /** + * Read a JSON source through the input stream. + * + * @param in the input stream (e.g. a file) + * @param eventListener listener for the events generated + * @return the parsed subscription items + */ + public static List readFrom( + final InputStream in, @Nullable final ImportExportEventListener eventListener) + throws InvalidSourceException { + if (in == null) { + throw new InvalidSourceException("input is null"); + } + + final List channels = new ArrayList<>(); + + try { + final JsonObject parentObject = JsonParser.object().from(in); + + if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) { + throw new InvalidSourceException("Channels array is null"); + } + + final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY); + + if (eventListener != null) { + eventListener.onSizeReceived(channelsArray.size()); + } + + for (Object o : channelsArray) { + if (o instanceof JsonObject) { + JsonObject itemObject = (JsonObject) o; + int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0); + String url = itemObject.getString(JSON_URL_KEY); + String name = itemObject.getString(JSON_NAME_KEY); + + if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) { + channels.add(new SubscriptionItem(serviceId, url, name)); + if (eventListener != null) { + eventListener.onItemCompleted(name); + } + } + } + } + } catch (Throwable e) { + throw new InvalidSourceException("Couldn't parse json", e); + } + + return channels; + } + + /** + * Write the subscriptions items list as JSON to the output. + * + * @param items the list of subscriptions items + * @param out the output stream (e.g. a file) + * @param eventListener listener for the events generated + */ + public static void writeTo(final List items, final OutputStream out, + @Nullable final ImportExportEventListener eventListener) { + JsonAppendableWriter writer = JsonWriter.on(out); + writeTo(items, writer, eventListener); + writer.done(); + } + + /** + * @see #writeTo(List, OutputStream, ImportExportEventListener) + * @param items the list of subscriptions items + * @param writer the output {@link JsonSink} + * @param eventListener listener for the events generated + */ + public static void writeTo(final List items, final JsonSink writer, + @Nullable final ImportExportEventListener eventListener) { + if (eventListener != null) { + eventListener.onSizeReceived(items.size()); + } + + writer.object(); + + writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME); + writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE); + + writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY); + for (SubscriptionItem item : items) { + writer.object(); + writer.value(JSON_SERVICE_ID_KEY, item.getServiceId()); + writer.value(JSON_URL_KEY, item.getUrl()); + writer.value(JSON_NAME_KEY, item.getName()); + writer.end(); + + if (eventListener != null) { + eventListener.onItemCompleted(item.getName()); + } + } + writer.end(); + + writer.end(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsExportService.java new file mode 100644 index 000000000..1a0b99f67 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsExportService.java @@ -0,0 +1,165 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsExportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.subscription.services; + +import android.content.Intent; +import android.text.TextUtils; +import android.util.Log; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + +public class SubscriptionsExportService extends BaseImportExportService { + public static final String KEY_FILE_PATH = "key_file_path"; + + /** + * A {@link LocalBroadcastManager local broadcast} will be made with this action + * when the export is successfully completed. + */ + public static final String EXPORT_COMPLETE_ACTION = "org.schabi.newpipelegacy.local" + + ".subscription.services.SubscriptionsExportService.EXPORT_COMPLETE"; + + private Subscription subscription; + private File outFile; + private FileOutputStream outputStream; + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (intent == null || subscription != null) { + return START_NOT_STICKY; + } + + final String path = intent.getStringExtra(KEY_FILE_PATH); + if (TextUtils.isEmpty(path)) { + stopAndReportError(new IllegalStateException( + "Exporting to a file, but the path is empty or null"), + "Exporting subscriptions"); + return START_NOT_STICKY; + } + + try { + outFile = new File(path); + outputStream = new FileOutputStream(outFile); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + + startExport(); + + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4567; + } + + @Override + public int getTitle() { + return R.string.export_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + if (subscription != null) { + subscription.cancel(); + } + } + + private void startExport() { + showToast(R.string.export_ongoing); + + subscriptionManager.subscriptionTable().getAll().take(1) + .map(subscriptionEntities -> { + final List result + = new ArrayList<>(subscriptionEntities.size()); + for (SubscriptionEntity entity : subscriptionEntities) { + result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), + entity.getName())); + } + return result; + }) + .map(exportToFile()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber getSubscriber() { + return new Subscriber() { + @Override + public void onSubscribe(final Subscription s) { + subscription = s; + s.request(1); + } + + @Override + public void onNext(final File file) { + if (DEBUG) { + Log.d(TAG, "startExport() success: file = " + file); + } + } + + @Override + public void onError(final Throwable error) { + Log.e(TAG, "onError() called with: error = [" + error + "]", error); + handleError(error); + } + + @Override + public void onComplete() { + LocalBroadcastManager.getInstance(SubscriptionsExportService.this) + .sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION)); + showToast(R.string.export_complete_toast); + stopService(); + } + }; + } + + private Function, File> exportToFile() { + return subscriptionItems -> { + ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); + return outFile; + }; + } + + protected void handleError(final Throwable error) { + super.handleError(R.string.subscriptions_export_unsuccessful, error); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsImportService.java new file mode 100644 index 000000000..cd2d65fa4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/local/subscription/services/SubscriptionsImportService.java @@ -0,0 +1,292 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsImportService.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.local.subscription.services; + +import android.content.Intent; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.ExceptionUtils; +import org.schabi.newpipelegacy.util.ExtractorHelper; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import io.reactivex.Flowable; +import io.reactivex.Notification; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.functions.Consumer; +import io.reactivex.functions.Function; +import io.reactivex.schedulers.Schedulers; + +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + +public class SubscriptionsImportService extends BaseImportExportService { + public static final int CHANNEL_URL_MODE = 0; + public static final int INPUT_STREAM_MODE = 1; + public static final int PREVIOUS_EXPORT_MODE = 2; + public static final String KEY_MODE = "key_mode"; + public static final String KEY_VALUE = "key_value"; + + /** + * A {@link LocalBroadcastManager local broadcast} will be made with this action + * when the import is successfully completed. + */ + public static final String IMPORT_COMPLETE_ACTION = "org.schabi.newpipelegacy.local" + + ".subscription.services.SubscriptionsImportService.IMPORT_COMPLETE"; + + /** + * How many extractions running in parallel. + */ + public static final int PARALLEL_EXTRACTIONS = 8; + + /** + * Number of items to buffer to mass-insert in the subscriptions table, + * this leads to a better performance as we can then use db transactions. + */ + public static final int BUFFER_COUNT_BEFORE_INSERT = 50; + + private Subscription subscription; + private int currentMode; + private int currentServiceId; + @Nullable + private String channelUrl; + @Nullable + private InputStream inputStream; + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (intent == null || subscription != null) { + return START_NOT_STICKY; + } + + currentMode = intent.getIntExtra(KEY_MODE, -1); + currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); + + if (currentMode == CHANNEL_URL_MODE) { + channelUrl = intent.getStringExtra(KEY_VALUE); + } else { + final String filePath = intent.getStringExtra(KEY_VALUE); + if (TextUtils.isEmpty(filePath)) { + stopAndReportError(new IllegalStateException( + "Importing from input stream, but file path is empty or null"), + "Importing subscriptions"); + return START_NOT_STICKY; + } + + try { + inputStream = new FileInputStream(new File(filePath)); + } catch (FileNotFoundException e) { + handleError(e); + return START_NOT_STICKY; + } + } + + if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { + final String errorDescription = "Some important field is null or in illegal state: " + + "currentMode=[" + currentMode + "], " + + "channelUrl=[" + channelUrl + "], " + + "inputStream=[" + inputStream + "]"; + stopAndReportError(new IllegalStateException(errorDescription), + "Importing subscriptions"); + return START_NOT_STICKY; + } + + startImport(); + return START_NOT_STICKY; + } + + @Override + protected int getNotificationId() { + return 4568; + } + + @Override + public int getTitle() { + return R.string.import_ongoing; + } + + @Override + protected void disposeAll() { + super.disposeAll(); + if (subscription != null) { + subscription.cancel(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Imports + //////////////////////////////////////////////////////////////////////////*/ + + private void startImport() { + showToast(R.string.import_ongoing); + + Flowable> flowable = null; + switch (currentMode) { + case CHANNEL_URL_MODE: + flowable = importFromChannelUrl(); + break; + case INPUT_STREAM_MODE: + flowable = importFromInputStream(); + break; + case PREVIOUS_EXPORT_MODE: + flowable = importFromPreviousExport(); + break; + } + + if (flowable == null) { + final String message = "Flowable given by \"importFrom\" is null " + + "(current mode: " + currentMode + ")"; + stopAndReportError(new IllegalStateException(message), "Importing subscriptions"); + return; + } + + flowable.doOnNext(subscriptionItems -> + eventListener.onSizeReceived(subscriptionItems.size())) + .flatMap(Flowable::fromIterable) + + .parallel(PARALLEL_EXTRACTIONS) + .runOn(Schedulers.io()) + .map((Function>) subscriptionItem -> { + try { + return Notification.createOnNext(ExtractorHelper + .getChannelInfo(subscriptionItem.getServiceId(), + subscriptionItem.getUrl(), true) + .blockingGet()); + } catch (Throwable e) { + return Notification.createOnError(e); + } + }) + .sequential() + + .observeOn(Schedulers.io()) + .doOnNext(getNotificationsConsumer()) + + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .map(upsertBatch()) + + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriber()); + } + + private Subscriber> getSubscriber() { + return new Subscriber>() { + @Override + public void onSubscribe(final Subscription s) { + subscription = s; + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(final List successfulInserted) { + if (DEBUG) { + Log.d(TAG, "startImport() " + successfulInserted.size() + + " items successfully inserted into the database"); + } + } + + @Override + public void onError(final Throwable error) { + Log.e(TAG, "Got an error!", error); + handleError(error); + } + + @Override + public void onComplete() { + LocalBroadcastManager.getInstance(SubscriptionsImportService.this) + .sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); + showToast(R.string.import_complete_toast); + stopService(); + } + }; + } + + private Consumer> getNotificationsConsumer() { + return notification -> { + if (notification.isOnNext()) { + String name = notification.getValue().getName(); + eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); + } else if (notification.isOnError()) { + final Throwable error = notification.getError(); + final Throwable cause = error.getCause(); + if (error instanceof IOException) { + throw (IOException) error; + } else if (cause instanceof IOException) { + throw (IOException) cause; + } else if (ExceptionUtils.isNetworkRelated(error)) { + throw new IOException(error); + } + + eventListener.onItemCompleted(""); + } + }; + } + + private Function>, List> upsertBatch() { + return notificationList -> { + final List infoList = new ArrayList<>(notificationList.size()); + for (Notification n : notificationList) { + if (n.isOnNext()) { + infoList.add(n.getValue()); + } + } + + return subscriptionManager.upsertAll(infoList); + }; + } + + private Flowable> importFromChannelUrl() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromChannelUrl(channelUrl)); + } + + private Flowable> importFromInputStream() { + return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromInputStream(inputStream)); + } + + private Flowable> importFromPreviousExport() { + return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); + } + + protected void handleError(@NonNull final Throwable error) { + super.handleError(R.string.subscriptions_import_unsuccessful, error); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/AudioServiceLeakFix.java b/app/src/main/java/org/schabi/newpipelegacy/player/AudioServiceLeakFix.java new file mode 100644 index 000000000..e90ae02ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/AudioServiceLeakFix.java @@ -0,0 +1,29 @@ +package org.schabi.newpipelegacy.player; + +import android.content.Context; +import android.content.ContextWrapper; + +/** + * Fixes a leak caused by AudioManager using an Activity context. + * Tracked at https://android-review.googlesource.com/#/c/140481/1 and + * https://github.com/square/leakcanary/issues/205 + * Source: + * https://gist.github.com/jankovd/891d96f476f7a9ce24e2 + */ +public class AudioServiceLeakFix extends ContextWrapper { + AudioServiceLeakFix(final Context base) { + super(base); + } + + public static ContextWrapper preventLeakOf(final Context base) { + return new AudioServiceLeakFix(base); + } + + @Override + public Object getSystemService(final String name) { + if (Context.AUDIO_SERVICE.equals(name)) { + return getApplicationContext().getSystemService(name); + } + return super.getSystemService(name); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayer.java b/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayer.java new file mode 100644 index 000000000..f43d9493b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayer.java @@ -0,0 +1,684 @@ +/* + * Copyright 2017 Mauricio Colli + * BackgroundPlayer.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.player; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.os.Build; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; + +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.source.MediaSource; +import com.nostra13.universalimageloader.core.assist.FailReason; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.player.event.PlayerEventListener; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.resolver.AudioPlaybackResolver; +import org.schabi.newpipelegacy.player.resolver.MediaSourceTag; +import org.schabi.newpipelegacy.util.BitmapUtils; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.getTimeString; +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +/** + * Service Background Player implementing {@link VideoPlayer}. + * + * @author mauriciocolli + */ +public final class BackgroundPlayer extends Service { + public static final String ACTION_CLOSE + = "org.schabi.newpipe.player.BackgroundPlayer.CLOSE"; + public static final String ACTION_PLAY_PAUSE + = "org.schabi.newpipe.player.BackgroundPlayer.PLAY_PAUSE"; + public static final String ACTION_REPEAT + = "org.schabi.newpipe.player.BackgroundPlayer.REPEAT"; + public static final String ACTION_PLAY_NEXT + = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_PLAY_NEXT"; + public static final String ACTION_PLAY_PREVIOUS + = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_PLAY_PREVIOUS"; + public static final String ACTION_FAST_REWIND + = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_FAST_REWIND"; + public static final String ACTION_FAST_FORWARD + = "org.schabi.newpipe.player.BackgroundPlayer.ACTION_FAST_FORWARD"; + + public static final String SET_IMAGE_RESOURCE_METHOD = "setImageResource"; + private static final String TAG = "BackgroundPlayer"; + private static final boolean DEBUG = BasePlayer.DEBUG; + private static final int NOTIFICATION_ID = 123789; + private static final int NOTIFICATION_UPDATES_BEFORE_RESET = 60; + private BasePlayerImpl basePlayerImpl; + + /*////////////////////////////////////////////////////////////////////////// + // Service-Activity Binder + //////////////////////////////////////////////////////////////////////////*/ + private SharedPreferences sharedPreferences; + + /*////////////////////////////////////////////////////////////////////////// + // Notification + //////////////////////////////////////////////////////////////////////////*/ + private PlayerEventListener activityListener; + private IBinder mBinder; + private NotificationManager notificationManager; + private NotificationCompat.Builder notBuilder; + private RemoteViews notRemoteView; + private RemoteViews bigNotRemoteView; + private boolean shouldUpdateOnProgress; + private int timesNotificationUpdated; + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate() { + if (DEBUG) { + Log.d(TAG, "onCreate() called"); + } + notificationManager = ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + assureCorrectAppLanguage(this); + ThemeHelper.setTheme(this); + basePlayerImpl = new BasePlayerImpl(this); + basePlayerImpl.setup(); + + mBinder = new PlayerServiceBinder(basePlayerImpl); + shouldUpdateOnProgress = true; + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "], " + + "flags = [" + flags + "], startId = [" + startId + "]"); + } + basePlayerImpl.handleIntent(intent); + if (basePlayerImpl.mediaSessionManager != null) { + basePlayerImpl.mediaSessionManager.handleMediaButtonIntent(intent); + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called"); + } + onClose(); + } + + @Override + protected void attachBaseContext(final Context base) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); + } + + @Override + public IBinder onBind(final Intent intent) { + return mBinder; + } + + /*////////////////////////////////////////////////////////////////////////// + // Actions + //////////////////////////////////////////////////////////////////////////*/ + private void onClose() { + if (DEBUG) { + Log.d(TAG, "onClose() called"); + } + + if (basePlayerImpl != null) { + basePlayerImpl.savePlaybackState(); + basePlayerImpl.stopActivityBinding(); + basePlayerImpl.destroy(); + } + if (notificationManager != null) { + notificationManager.cancel(NOTIFICATION_ID); + } + mBinder = null; + basePlayerImpl = null; + + stopForeground(true); + stopSelf(); + } + + private void onScreenOnOff(final boolean on) { + if (DEBUG) { + Log.d(TAG, "onScreenOnOff() called with: on = [" + on + "]"); + } + shouldUpdateOnProgress = on; + basePlayerImpl.triggerProgressUpdate(); + if (on) { + basePlayerImpl.startProgressLoop(); + } else { + basePlayerImpl.stopProgressLoop(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Notification + //////////////////////////////////////////////////////////////////////////*/ + + private void resetNotification() { + notBuilder = createNotification(); + timesNotificationUpdated = 0; + } + + private NotificationCompat.Builder createNotification() { + notRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, + R.layout.player_background_notification); + bigNotRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, + R.layout.player_background_notification_expanded); + + setupNotification(notRemoteView); + setupNotification(bigNotRemoteView); + + NotificationCompat.Builder builder = new NotificationCompat + .Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCustomContentView(notRemoteView) + .setCustomBigContentView(bigNotRemoteView); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + setLockScreenThumbnail(builder); + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + builder.setPriority(NotificationCompat.PRIORITY_MAX); + } + return builder; + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + private void setLockScreenThumbnail(final NotificationCompat.Builder builder) { + boolean isLockScreenThumbnailEnabled = sharedPreferences.getBoolean( + getString(R.string.enable_lock_screen_video_thumbnail_key), true); + + if (isLockScreenThumbnailEnabled) { + basePlayerImpl.mediaSessionManager.setLockScreenArt( + builder, + getCenteredThumbnailBitmap() + ); + } else { + basePlayerImpl.mediaSessionManager.clearLockScreenArt(builder); + } + } + + @Nullable + private Bitmap getCenteredThumbnailBitmap() { + final int screenWidth = Resources.getSystem().getDisplayMetrics().widthPixels; + final int screenHeight = Resources.getSystem().getDisplayMetrics().heightPixels; + + return BitmapUtils.centerCrop(basePlayerImpl.getThumbnail(), screenWidth, screenHeight); + } + + private void setupNotification(final RemoteViews remoteViews) { + if (basePlayerImpl == null) { + return; + } + + remoteViews.setTextViewText(R.id.notificationSongName, basePlayerImpl.getVideoTitle()); + remoteViews.setTextViewText(R.id.notificationArtist, basePlayerImpl.getUploaderName()); + + remoteViews.setOnClickPendingIntent(R.id.notificationPlayPause, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_PLAY_PAUSE), PendingIntent.FLAG_UPDATE_CURRENT)); + remoteViews.setOnClickPendingIntent(R.id.notificationStop, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_CLOSE), PendingIntent.FLAG_UPDATE_CURRENT)); + remoteViews.setOnClickPendingIntent(R.id.notificationRepeat, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_REPEAT), PendingIntent.FLAG_UPDATE_CURRENT)); + + // Starts background player activity -- attempts to unlock lockscreen + final Intent intent = NavigationHelper.getBackgroundPlayerActivityIntent(this); + remoteViews.setOnClickPendingIntent(R.id.notificationContent, + PendingIntent.getActivity(this, NOTIFICATION_ID, intent, + PendingIntent.FLAG_UPDATE_CURRENT)); + + if (basePlayerImpl.playQueue != null && basePlayerImpl.playQueue.size() > 1) { + remoteViews.setInt(R.id.notificationFRewind, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_previous); + remoteViews.setInt(R.id.notificationFForward, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_next); + remoteViews.setOnClickPendingIntent(R.id.notificationFRewind, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_PLAY_PREVIOUS), PendingIntent.FLAG_UPDATE_CURRENT)); + remoteViews.setOnClickPendingIntent(R.id.notificationFForward, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_PLAY_NEXT), PendingIntent.FLAG_UPDATE_CURRENT)); + } else { + remoteViews.setInt(R.id.notificationFRewind, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_rewind); + remoteViews.setInt(R.id.notificationFForward, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_fastforward); + remoteViews.setOnClickPendingIntent(R.id.notificationFRewind, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_FAST_REWIND), PendingIntent.FLAG_UPDATE_CURRENT)); + remoteViews.setOnClickPendingIntent(R.id.notificationFForward, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, + new Intent(ACTION_FAST_FORWARD), PendingIntent.FLAG_UPDATE_CURRENT)); + } + + setRepeatModeIcon(remoteViews, basePlayerImpl.getRepeatMode()); + } + + /** + * Updates the notification, and the play/pause button in it. + * Used for changes on the remoteView + * + * @param drawableId if != -1, sets the drawable with that id on the play/pause button + */ + private synchronized void updateNotification(final int drawableId) { +// if (DEBUG) { +// Log.d(TAG, "updateNotification() called with: drawableId = [" + drawableId + "]"); +// } + if (notBuilder == null) { + return; + } + if (drawableId != -1) { + if (notRemoteView != null) { + notRemoteView.setImageViewResource(R.id.notificationPlayPause, drawableId); + } + if (bigNotRemoteView != null) { + bigNotRemoteView.setImageViewResource(R.id.notificationPlayPause, drawableId); + } + } + notificationManager.notify(NOTIFICATION_ID, notBuilder.build()); + timesNotificationUpdated++; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void setRepeatModeIcon(final RemoteViews remoteViews, final int repeatMode) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + remoteViews.setInt(R.id.notificationRepeat, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + remoteViews.setInt(R.id.notificationRepeat, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + remoteViews.setInt(R.id.notificationRepeat, SET_IMAGE_RESOURCE_METHOD, + R.drawable.exo_controls_repeat_all); + break; + } + } + ////////////////////////////////////////////////////////////////////////// + + protected class BasePlayerImpl extends BasePlayer { + @NonNull + private final AudioPlaybackResolver resolver; + private int cachedDuration; + private String cachedDurationString; + + BasePlayerImpl(final Context context) { + super(context); + this.resolver = new AudioPlaybackResolver(context, dataSource); + } + + @Override + public void initPlayer(final boolean playOnReady) { + super.initPlayer(playOnReady); + } + + @Override + public void handleIntent(final Intent intent) { + super.handleIntent(intent); + + resetNotification(); + if (bigNotRemoteView != null) { + bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false); + } + if (notRemoteView != null) { + notRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 0, false); + } + startForeground(NOTIFICATION_ID, notBuilder.build()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail Loading + //////////////////////////////////////////////////////////////////////////*/ + + private void updateNotificationThumbnail() { + if (basePlayerImpl == null) { + return; + } + if (notRemoteView != null) { + notRemoteView.setImageViewBitmap(R.id.notificationCover, + basePlayerImpl.getThumbnail()); + } + if (bigNotRemoteView != null) { + bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, + basePlayerImpl.getThumbnail()); + } + } + + @Override + public void onLoadingComplete(final String imageUri, final View view, + final Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + resetNotification(); + updateNotificationThumbnail(); + updateNotification(-1); + } + + @Override + public void onLoadingFailed(final String imageUri, final View view, + final FailReason failReason) { + super.onLoadingFailed(imageUri, view, failReason); + resetNotification(); + updateNotificationThumbnail(); + updateNotification(-1); + } + + /*////////////////////////////////////////////////////////////////////////// + // States Implementation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onPrepared(final boolean playWhenReady) { + super.onPrepared(playWhenReady); + } + + @Override + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlayback(); + } + + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + updatePlayback(); + } + + @Override + public void onUpdateProgress(final int currentProgress, final int duration, + final int bufferPercent) { + updateProgress(currentProgress, duration, bufferPercent); + + if (!shouldUpdateOnProgress) { + return; + } + if (timesNotificationUpdated > NOTIFICATION_UPDATES_BEFORE_RESET) { + resetNotification(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O /*Oreo*/) { + updateNotificationThumbnail(); + } + } + if (bigNotRemoteView != null) { + if (cachedDuration != duration) { + cachedDuration = duration; + cachedDurationString = getTimeString(duration); + } + bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, duration, + currentProgress, false); + bigNotRemoteView.setTextViewText(R.id.notificationTime, + getTimeString(currentProgress) + " / " + cachedDurationString); + } + if (notRemoteView != null) { + notRemoteView.setProgressBar(R.id.notificationProgressBar, duration, + currentProgress, false); + } + updateNotification(-1); + } + + @Override + public void onPlayPrevious() { + super.onPlayPrevious(); + triggerProgressUpdate(); + } + + @Override + public void onPlayNext() { + super.onPlayNext(); + triggerProgressUpdate(); + } + + @Override + public void destroy() { + super.destroy(); + if (notRemoteView != null) { + notRemoteView.setImageViewBitmap(R.id.notificationCover, null); + } + if (bigNotRemoteView != null) { + bigNotRemoteView.setImageViewBitmap(R.id.notificationCover, null); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + updatePlayback(); + } + + @Override + public void onLoadingChanged(final boolean isLoading) { + // Disable default behavior + } + + @Override + public void onRepeatModeChanged(final int i) { + resetNotification(); + updateNotification(-1); + updatePlayback(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected void onMetadataChanged(@NonNull final MediaSourceTag tag) { + super.onMetadataChanged(tag); + resetNotification(); + updateNotificationThumbnail(); + updateNotification(-1); + updateMetadata(); + } + + @Override + @Nullable + public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + return resolver.resolve(info); + } + + @Override + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); + onClose(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Activity Event Listener + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void setActivityListener(final PlayerEventListener listener) { + activityListener = listener; + updateMetadata(); + updatePlayback(); + triggerProgressUpdate(); + } + + /*package-private*/ void removeActivityListener(final PlayerEventListener listener) { + if (activityListener == listener) { + activityListener = null; + } + } + + private void updateMetadata() { + if (activityListener != null && getCurrentMetadata() != null) { + activityListener.onMetadataUpdate(getCurrentMetadata().getMetadata()); + } + } + + private void updatePlayback() { + if (activityListener != null && simpleExoPlayer != null && playQueue != null) { + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), getPlaybackParameters()); + } + } + + private void updateProgress(final int currentProgress, final int duration, + final int bufferPercent) { + if (activityListener != null) { + activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); + } + } + + private void stopActivityBinding() { + if (activityListener != null) { + activityListener.onServiceStopped(); + activityListener = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast Receiver + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void setupBroadcastReceiver(final IntentFilter intentFltr) { + super.setupBroadcastReceiver(intentFltr); + intentFltr.addAction(ACTION_CLOSE); + intentFltr.addAction(ACTION_PLAY_PAUSE); + intentFltr.addAction(ACTION_REPEAT); + intentFltr.addAction(ACTION_PLAY_PREVIOUS); + intentFltr.addAction(ACTION_PLAY_NEXT); + intentFltr.addAction(ACTION_FAST_REWIND); + intentFltr.addAction(ACTION_FAST_FORWARD); + + intentFltr.addAction(Intent.ACTION_SCREEN_ON); + intentFltr.addAction(Intent.ACTION_SCREEN_OFF); + + intentFltr.addAction(Intent.ACTION_HEADSET_PLUG); + } + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (intent == null || intent.getAction() == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); + } + switch (intent.getAction()) { + case ACTION_CLOSE: + onClose(); + break; + case ACTION_PLAY_PAUSE: + onPlayPause(); + break; + case ACTION_REPEAT: + onRepeatClicked(); + break; + case ACTION_PLAY_NEXT: + onPlayNext(); + break; + case ACTION_PLAY_PREVIOUS: + onPlayPrevious(); + break; + case ACTION_FAST_FORWARD: + onFastForward(); + break; + case ACTION_FAST_REWIND: + onFastRewind(); + break; + case Intent.ACTION_SCREEN_ON: + onScreenOnOff(true); + break; + case Intent.ACTION_SCREEN_OFF: + onScreenOnOff(false); + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // States + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void changeState(final int state) { + super.changeState(state); + updatePlayback(); + } + + @Override + public void onPlaying() { + super.onPlaying(); + resetNotification(); + updateNotificationThumbnail(); + updateNotification(R.drawable.exo_controls_pause); + } + + @Override + public void onPaused() { + super.onPaused(); + resetNotification(); + updateNotificationThumbnail(); + updateNotification(R.drawable.exo_controls_play); + } + + @Override + public void onCompleted() { + super.onCompleted(); + resetNotification(); + if (bigNotRemoteView != null) { + bigNotRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 100, false); + } + if (notRemoteView != null) { + notRemoteView.setProgressBar(R.id.notificationProgressBar, 100, 100, false); + } + updateNotificationThumbnail(); + updateNotification(R.drawable.ic_replay_white_24dp); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayerActivity.java b/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayerActivity.java new file mode 100644 index 000000000..d7551708e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/BackgroundPlayerActivity.java @@ -0,0 +1,73 @@ +package org.schabi.newpipelegacy.player; + +import android.content.Intent; +import android.view.MenuItem; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.PermissionHelper; + +import static org.schabi.newpipelegacy.player.BackgroundPlayer.ACTION_CLOSE; + +public final class BackgroundPlayerActivity extends ServicePlayerActivity { + + private static final String TAG = "BackgroundPlayerActivity"; + + @Override + public String getTag() { + return TAG; + } + + @Override + public String getSupportActionTitle() { + return getResources().getString(R.string.title_activity_background_player); + } + + @Override + public Intent getBindIntent() { + return new Intent(this, BackgroundPlayer.class); + } + + @Override + public void startPlayerListener() { + if (player != null && player instanceof BackgroundPlayer.BasePlayerImpl) { + ((BackgroundPlayer.BasePlayerImpl) player).setActivityListener(this); + } + } + + @Override + public void stopPlayerListener() { + if (player != null && player instanceof BackgroundPlayer.BasePlayerImpl) { + ((BackgroundPlayer.BasePlayerImpl) player).removeActivityListener(this); + } + } + + @Override + public int getPlayerOptionMenuResource() { + return R.menu.menu_play_queue_bg; + } + + @Override + public boolean onPlayerOptionSelected(final MenuItem item) { + if (item.getItemId() == R.id.action_switch_popup) { + + if (!PermissionHelper.isPopupEnabled(this)) { + PermissionHelper.showPopupEnablementToast(this); + return true; + } + + this.player.setRecovery(); + getApplicationContext().sendBroadcast(getPlayerShutdownIntent()); + getApplicationContext().startService( + getSwitchIntent(PopupVideoPlayer.class) + .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()) + ); + return true; + } + return false; + } + + @Override + public Intent getPlayerShutdownIntent() { + return new Intent(ACTION_CLOSE); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/BasePlayer.java b/app/src/main/java/org/schabi/newpipelegacy/player/BasePlayer.java new file mode 100644 index 000000000..1ef1dc24b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/BasePlayer.java @@ -0,0 +1,1549 @@ +/* + * Copyright 2017 Mauricio Colli + * BasePlayer.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.player; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.AudioManager; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.View; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.BehindLiveWindowException; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.DownloaderImpl; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.player.helper.AudioReactor; +import org.schabi.newpipelegacy.player.helper.LoadController; +import org.schabi.newpipelegacy.player.helper.MediaSessionManager; +import org.schabi.newpipelegacy.player.helper.PlayerDataSource; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.player.playback.BasePlayerMediaSession; +import org.schabi.newpipelegacy.player.playback.CustomTrackSelector; +import org.schabi.newpipelegacy.player.playback.MediaSourceManager; +import org.schabi.newpipelegacy.player.playback.PlaybackListener; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueAdapter; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.resolver.MediaSourceTag; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.SerializedCache; + +import java.io.IOException; + +import io.reactivex.Observable; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.disposables.SerialDisposable; + +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_PERIOD_TRANSITION; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; +import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; +import static io.reactivex.android.schedulers.AndroidSchedulers.mainThread; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +/** + * Base for the players, joining the common properties. + * + * @author mauriciocolli + */ +@SuppressWarnings({"WeakerAccess"}) +public abstract class BasePlayer implements + Player.EventListener, PlaybackListener, ImageLoadingListener { + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + @NonNull + public static final String TAG = "BasePlayer"; + + public static final int STATE_PREFLIGHT = -1; + public static final int STATE_BLOCKED = 123; + public static final int STATE_PLAYING = 124; + public static final int STATE_BUFFERING = 125; + public static final int STATE_PAUSED = 126; + public static final int STATE_PAUSED_SEEK = 127; + public static final int STATE_COMPLETED = 128; + + /*////////////////////////////////////////////////////////////////////////// + // Intent + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + public static final String REPEAT_MODE = "repeat_mode"; + @NonNull + public static final String PLAYBACK_QUALITY = "playback_quality"; + @NonNull + public static final String PLAY_QUEUE_KEY = "play_queue_key"; + @NonNull + public static final String APPEND_ONLY = "append_only"; + @NonNull + public static final String RESUME_PLAYBACK = "resume_playback"; + @NonNull + public static final String START_PAUSED = "start_paused"; + @NonNull + public static final String SELECT_ON_APPEND = "select_on_append"; + @NonNull + public static final String IS_MUTED = "is_muted"; + + /*////////////////////////////////////////////////////////////////////////// + // Playback + //////////////////////////////////////////////////////////////////////////*/ + + protected static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f}; + + protected PlayQueue playQueue; + protected PlayQueueAdapter playQueueAdapter; + + @Nullable + protected MediaSourceManager playbackManager; + + @Nullable + private PlayQueueItem currentItem; + @Nullable + private MediaSourceTag currentMetadata; + @Nullable + private Bitmap currentThumbnail; + + @Nullable + protected Toast errorToast; + + /*////////////////////////////////////////////////////////////////////////// + // Player + //////////////////////////////////////////////////////////////////////////*/ + + protected static final int PLAY_PREV_ACTIVATION_LIMIT_MILLIS = 5000; // 5 seconds + protected static final int PROGRESS_LOOP_INTERVAL_MILLIS = 500; + + protected SimpleExoPlayer simpleExoPlayer; + protected AudioReactor audioReactor; + protected MediaSessionManager mediaSessionManager; + + + @NonNull + protected final Context context; + @NonNull + protected final BroadcastReceiver broadcastReceiver; + @NonNull + protected final IntentFilter intentFilter; + @NonNull + protected final HistoryRecordManager recordManager; + @NonNull + protected final CustomTrackSelector trackSelector; + @NonNull + protected final PlayerDataSource dataSource; + @NonNull + private final LoadControl loadControl; + + @NonNull + private final RenderersFactory renderFactory; + @NonNull + private final SerialDisposable progressUpdateReactor; + @NonNull + private final CompositeDisposable databaseUpdateReactor; + + private boolean isPrepared = false; + private Disposable stateLoader; + + protected int currentState = STATE_PREFLIGHT; + + public BasePlayer(@NonNull final Context context) { + this.context = context; + + this.broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context ctx, final Intent intent) { + onBroadcastReceived(intent); + } + }; + this.intentFilter = new IntentFilter(); + setupBroadcastReceiver(intentFilter); + + this.recordManager = new HistoryRecordManager(context); + + this.progressUpdateReactor = new SerialDisposable(); + this.databaseUpdateReactor = new CompositeDisposable(); + + final String userAgent = DownloaderImpl.USER_AGENT; + final DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter.Builder(context) + .build(); + this.dataSource = new PlayerDataSource(context, userAgent, bandwidthMeter); + + final TrackSelection.Factory trackSelectionFactory = PlayerHelper + .getQualitySelector(context); + this.trackSelector = new CustomTrackSelector(context, trackSelectionFactory); + + this.loadControl = new LoadController(); + this.renderFactory = new DefaultRenderersFactory(context); + } + + public void setup() { + if (simpleExoPlayer == null) { + initPlayer(/*playOnInit=*/true); + } + initListeners(); + } + + public void initPlayer(final boolean playOnReady) { + if (DEBUG) { + Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); + } + + simpleExoPlayer = new SimpleExoPlayer.Builder(context, renderFactory) + .setTrackSelector(trackSelector) + .setLoadControl(loadControl) + .build(); + simpleExoPlayer.addListener(this); + simpleExoPlayer.setPlayWhenReady(playOnReady); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); + simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); + simpleExoPlayer.setHandleAudioBecomingNoisy(true); + + audioReactor = new AudioReactor(context, simpleExoPlayer); + mediaSessionManager = new MediaSessionManager(context, simpleExoPlayer, + new BasePlayerMediaSession(this)); + + registerBroadcastReceiver(); + } + + public void initListeners() { } + + public void handleIntent(final Intent intent) { + if (DEBUG) { + Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); + } + if (intent == null) { + return; + } + + // Resolve play queue + if (!intent.hasExtra(PLAY_QUEUE_KEY)) { + return; + } + final String intentCacheKey = intent.getStringExtra(PLAY_QUEUE_KEY); + final PlayQueue queue = SerializedCache.getInstance().take(intentCacheKey, PlayQueue.class); + if (queue == null) { + return; + } + + // Resolve append intents + if (intent.getBooleanExtra(APPEND_ONLY, false) && playQueue != null) { + int sizeBeforeAppend = playQueue.size(); + playQueue.append(queue.getStreams()); + + if ((intent.getBooleanExtra(SELECT_ON_APPEND, false) + || getCurrentState() == STATE_COMPLETED) && queue.getStreams().size() > 0) { + playQueue.setIndex(sizeBeforeAppend); + } + + return; + } + + final PlaybackParameters savedParameters = retrievePlaybackParametersFromPreferences(); + final float playbackSpeed = savedParameters.speed; + final float playbackPitch = savedParameters.pitch; + final boolean playbackSkipSilence = savedParameters.skipSilence; + + final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); + final boolean isMuted = intent + .getBooleanExtra(IS_MUTED, simpleExoPlayer != null && isMuted()); + + // seek to timestamp if stream is already playing + if (simpleExoPlayer != null + && queue.size() == 1 + && playQueue != null + && playQueue.getItem() != null + && queue.getItem().getUrl().equals(playQueue.getItem().getUrl()) + && queue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET + ) { + simpleExoPlayer.seekTo(playQueue.getIndex(), queue.getItem().getRecoveryPosition()); + return; + } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) && isPlaybackResumeEnabled()) { + final PlayQueueItem item = queue.getItem(); + if (item != null && item.getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { + stateLoader = recordManager.loadStreamState(item) + .observeOn(mainThread()) + .doFinally(() -> initPlayback(queue, repeatMode, playbackSpeed, + playbackPitch, playbackSkipSilence, true, isMuted)) + .subscribe( + state -> queue + .setRecovery(queue.getIndex(), state.getProgressTime()), + error -> { + if (DEBUG) { + error.printStackTrace(); + } + } + ); + databaseUpdateReactor.add(stateLoader); + return; + } + } + // Good to go... + initPlayback(queue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, + /*playOnInit=*/!intent.getBooleanExtra(START_PAUSED, false), isMuted); + } + + private PlaybackParameters retrievePlaybackParametersFromPreferences() { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); + + final float speed = preferences.getFloat( + context.getString(R.string.playback_speed_key), getPlaybackSpeed()); + final float pitch = preferences.getFloat( + context.getString(R.string.playback_pitch_key), getPlaybackPitch()); + final boolean skipSilence = preferences.getBoolean( + context.getString(R.string.playback_skip_silence_key), getPlaybackSkipSilence()); + return new PlaybackParameters(speed, pitch, skipSilence); + } + + protected void initPlayback(@NonNull final PlayQueue queue, + @Player.RepeatMode final int repeatMode, + final float playbackSpeed, + final float playbackPitch, + final boolean playbackSkipSilence, + final boolean playOnReady, + final boolean isMuted) { + destroyPlayer(); + initPlayer(playOnReady); + setRepeatMode(repeatMode); + setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence); + + playQueue = queue; + playQueue.init(); + if (playbackManager != null) { + playbackManager.dispose(); + } + playbackManager = new MediaSourceManager(this, playQueue); + + if (playQueueAdapter != null) { + playQueueAdapter.dispose(); + } + playQueueAdapter = new PlayQueueAdapter(context, playQueue); + + simpleExoPlayer.setVolume(isMuted ? 0 : 1); + } + + public void destroyPlayer() { + if (DEBUG) { + Log.d(TAG, "destroyPlayer() called"); + } + if (simpleExoPlayer != null) { + simpleExoPlayer.removeListener(this); + simpleExoPlayer.stop(); + simpleExoPlayer.release(); + } + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + if (playQueue != null) { + playQueue.dispose(); + } + if (audioReactor != null) { + audioReactor.dispose(); + } + if (playbackManager != null) { + playbackManager.dispose(); + } + if (mediaSessionManager != null) { + mediaSessionManager.dispose(); + } + if (stateLoader != null) { + stateLoader.dispose(); + } + + if (playQueueAdapter != null) { + playQueueAdapter.unsetSelectedListener(); + playQueueAdapter.dispose(); + } + } + + public void destroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called"); + } + destroyPlayer(); + unregisterBroadcastReceiver(); + + databaseUpdateReactor.clear(); + progressUpdateReactor.set(null); + } + + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail Loading + //////////////////////////////////////////////////////////////////////////*/ + + private void initThumbnail(final String url) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - initThumbnail() called"); + } + if (url == null || url.isEmpty()) { + return; + } + ImageLoader.getInstance().resume(); + ImageLoader.getInstance() + .loadImage(url, ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS, this); + } + + @Override + public void onLoadingStarted(final String imageUri, final View view) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onLoadingStarted() called on: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + } + + @Override + public void onLoadingFailed(final String imageUri, final View view, + final FailReason failReason) { + Log.e(TAG, "Thumbnail - onLoadingFailed() called on imageUri = [" + imageUri + "]", + failReason.getCause()); + currentThumbnail = null; + } + + @Override + public void onLoadingComplete(final String imageUri, final View view, + final Bitmap loadedImage) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onLoadingComplete() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "], " + + "loadedImage = [" + loadedImage + "]"); + } + currentThumbnail = loadedImage; + } + + @Override + public void onLoadingCancelled(final String imageUri, final View view) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onLoadingCancelled() called with: " + + "imageUri = [" + imageUri + "], view = [" + view + "]"); + } + currentThumbnail = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast Receiver + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Add your action in the intentFilter. + * + * @param intentFltr intent filter that will be used for register the receiver + */ + protected void setupBroadcastReceiver(final IntentFilter intentFltr) { + intentFltr.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); + } + + public void onBroadcastReceived(final Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + switch (intent.getAction()) { + case AudioManager.ACTION_AUDIO_BECOMING_NOISY: + onPause(); + break; + } + } + + protected void registerBroadcastReceiver() { + // Try to unregister current first + unregisterBroadcastReceiver(); + context.registerReceiver(broadcastReceiver, intentFilter); + } + + protected void unregisterBroadcastReceiver() { + try { + context.unregisterReceiver(broadcastReceiver); + } catch (final IllegalArgumentException unregisteredException) { + Log.w(TAG, "Broadcast receiver already unregistered " + + "(" + unregisteredException.getMessage() + ")"); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // States Implementation + //////////////////////////////////////////////////////////////////////////*/ + + public void changeState(final int state) { + if (DEBUG) { + Log.d(TAG, "changeState() called with: state = [" + state + "]"); + } + currentState = state; + switch (state) { + case STATE_BLOCKED: + onBlocked(); + break; + case STATE_PLAYING: + onPlaying(); + break; + case STATE_BUFFERING: + onBuffering(); + break; + case STATE_PAUSED: + onPaused(); + break; + case STATE_PAUSED_SEEK: + onPausedSeek(); + break; + case STATE_COMPLETED: + onCompleted(); + break; + } + } + + public void onBlocked() { + if (DEBUG) { + Log.d(TAG, "onBlocked() called"); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + } + + public void onPlaying() { + if (DEBUG) { + Log.d(TAG, "onPlaying() called"); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + } + + public void onBuffering() { + } + + public void onPaused() { + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + } + + public void onPausedSeek() { } + + public void onCompleted() { + if (DEBUG) { + Log.d(TAG, "onCompleted() called"); + } + if (playQueue.getIndex() < playQueue.size() - 1) { + playQueue.offsetIndex(+1); + } + if (isProgressLoopRunning()) { + stopProgressLoop(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Repeat and shuffle + //////////////////////////////////////////////////////////////////////////*/ + + public void onRepeatClicked() { + if (DEBUG) { + Log.d(TAG, "onRepeatClicked() called"); + } + + final int mode; + + switch (getRepeatMode()) { + case Player.REPEAT_MODE_OFF: + mode = Player.REPEAT_MODE_ONE; + break; + case Player.REPEAT_MODE_ONE: + mode = Player.REPEAT_MODE_ALL; + break; + case Player.REPEAT_MODE_ALL: + default: + mode = Player.REPEAT_MODE_OFF; + break; + } + + setRepeatMode(mode); + if (DEBUG) { + Log.d(TAG, "onRepeatClicked() currentRepeatMode = " + getRepeatMode()); + } + } + + public void onShuffleClicked() { + if (DEBUG) { + Log.d(TAG, "onShuffleClicked() called"); + } + + if (simpleExoPlayer == null) { + return; + } + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); + } + /*////////////////////////////////////////////////////////////////////////// + // Mute / Unmute + //////////////////////////////////////////////////////////////////////////*/ + + public void onMuteUnmuteButtonClicked() { + if (DEBUG) { + Log.d(TAG, "onMuteUnmuteButtonClicled() called"); + } + simpleExoPlayer.setVolume(isMuted() ? 1 : 0); + } + + public boolean isMuted() { + return simpleExoPlayer.getVolume() == 0; + } + + /*////////////////////////////////////////////////////////////////////////// + // Progress Updates + //////////////////////////////////////////////////////////////////////////*/ + + public abstract void onUpdateProgress(int currentProgress, int duration, int bufferPercent); + + protected void startProgressLoop() { + progressUpdateReactor.set(getProgressReactor()); + } + + protected void stopProgressLoop() { + progressUpdateReactor.set(null); + } + + public void triggerProgressUpdate() { + if (simpleExoPlayer == null) { + return; + } + onUpdateProgress( + Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), + (int) simpleExoPlayer.getDuration(), + simpleExoPlayer.getBufferedPercentage() + ); + } + + private Disposable getProgressReactor() { + return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, mainThread()) + .observeOn(mainThread()) + .subscribe(ignored -> triggerProgressUpdate(), + error -> Log.e(TAG, "Progress update failure: ", error)); + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onTimelineChanged(final Timeline timeline, final int reason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onTimelineChanged() called with " + + "timeline size = [" + timeline.getWindowCount() + "], " + + "reason = [" + reason + "]"); + } + + maybeUpdateCurrentMetadata(); + } + + @Override + public void onTracksChanged(final TrackGroupArray trackGroups, + final TrackSelectionArray trackSelections) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onTracksChanged(), " + + "track group size = " + trackGroups.length); + } + + maybeUpdateCurrentMetadata(); + } + + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - playbackParameters(), " + + "speed: " + playbackParameters.speed + ", " + + "pitch: " + playbackParameters.pitch); + } + } + + @Override + public void onLoadingChanged(final boolean isLoading) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + + "isLoading = [" + isLoading + "]"); + } + + if (!isLoading && getCurrentState() == STATE_PAUSED && isProgressLoopRunning()) { + stopProgressLoop(); + } else if (isLoading && !isProgressLoopRunning()) { + startProgressLoop(); + } + + maybeUpdateCurrentMetadata(); + } + + @Override + public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "playbackState = [" + playbackState + "]"); + } + + if (getCurrentState() == STATE_PAUSED_SEEK) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayerStateChanged() is currently blocked"); + } + return; + } + + switch (playbackState) { + case Player.STATE_IDLE: // 1 + isPrepared = false; + break; + case Player.STATE_BUFFERING: // 2 + if (isPrepared) { + changeState(STATE_BUFFERING); + } + break; + case Player.STATE_READY: //3 + maybeUpdateCurrentMetadata(); + maybeCorrectSeekPosition(); + if (!isPrepared) { + isPrepared = true; + onPrepared(playWhenReady); + break; + } + changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + break; + case Player.STATE_ENDED: // 4 + changeState(STATE_COMPLETED); + if (currentMetadata != null) { + resetPlaybackState(currentMetadata.getMetadata()); + } + isPrepared = false; + break; + } + } + + private void maybeCorrectSeekPosition() { + if (playQueue == null || simpleExoPlayer == null || currentMetadata == null) { + return; + } + + final PlayQueueItem currentSourceItem = playQueue.getItem(); + if (currentSourceItem == null) { + return; + } + + final StreamInfo currentInfo = currentMetadata.getMetadata(); + final long presetStartPositionMillis = currentInfo.getStartPosition() * 1000; + if (presetStartPositionMillis > 0L) { + // Has another start position? + if (DEBUG) { + Log.d(TAG, "Playback - Seeking to preset start " + + "position=[" + presetStartPositionMillis + "]"); + } + seekTo(presetStartPositionMillis); + } + } + + /** + * Process 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(final ExoPlaybackException error) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPlayerError() called with: " + "error = [" + error + "]"); + } + if (errorToast != null) { + errorToast.cancel(); + errorToast = null; + } + + savePlaybackState(); + + switch (error.type) { + case ExoPlaybackException.TYPE_SOURCE: + processSourceError(error.getSourceException()); + showStreamError(error); + break; + case ExoPlaybackException.TYPE_UNEXPECTED: + showRecoverableError(error); + setRecovery(); + reload(); + break; + default: + showUnrecoverableError(error); + onPlaybackShutdown(); + break; + } + } + + private void processSourceError(final IOException error) { + if (simpleExoPlayer == null || playQueue == null) { + return; + } + setRecovery(); + + final Throwable cause = error.getCause(); + if (error instanceof BehindLiveWindowException) { + reload(); + } else { + playQueue.error(); + } + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason final int reason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + + "reason = [" + reason + "]"); + } + if (playQueue == null) { + return; + } + + // Refresh the playback if there is a transition to the next video + final int newWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + switch (reason) { + case DISCONTINUITY_REASON_PERIOD_TRANSITION: + // When player is in single repeat mode and a period transition occurs, + // we need to register a view count here since no metadata has changed + if (getRepeatMode() == Player.REPEAT_MODE_ONE + && newWindowIndex == playQueue.getIndex()) { + registerView(); + break; + } + case DISCONTINUITY_REASON_SEEK: + case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + case DISCONTINUITY_REASON_INTERNAL: + if (playQueue.getIndex() != newWindowIndex) { + resetPlaybackState(playQueue.getItem()); + playQueue.setIndex(newWindowIndex); + } + break; + } + + maybeUpdateCurrentMetadata(); + } + + @Override + public void onRepeatModeChanged(@Player.RepeatMode final int reason) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + + "mode = [" + reason + "]"); + } + } + + @Override + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + + "mode = [" + shuffleModeEnabled + "]"); + } + if (playQueue == null) { + return; + } + if (shuffleModeEnabled) { + playQueue.shuffle(); + } else { + playQueue.unshuffle(); + } + } + + @Override + public void onSeekProcessed() { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); + } + if (isPrepared) { + savePlaybackState(); + } + } + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { + // If live, then not near playback edge + // If not playing, then not approaching playback edge + if (simpleExoPlayer == null || isLive() || !isPlaying()) { + return false; + } + + final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); + final long currentDurationMillis = simpleExoPlayer.getDuration(); + return currentDurationMillis - currentPositionMillis < timeToEndMillis; + } + + @Override + public void onPlaybackBlock() { + if (simpleExoPlayer == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackBlock() called"); + } + + currentItem = null; + currentMetadata = null; + simpleExoPlayer.stop(); + isPrepared = false; + + changeState(STATE_BLOCKED); + } + + @Override + public void onPlaybackUnblock(final MediaSource mediaSource) { + if (simpleExoPlayer == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackUnblock() called"); + } + + if (getCurrentState() == STATE_BLOCKED) { + changeState(STATE_BUFFERING); + } + + simpleExoPlayer.prepare(mediaSource); + } + + public void onPlaybackSynchronize(@NonNull final PlayQueueItem item) { + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackSynchronize() called with " + + "item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); + } + if (simpleExoPlayer == null || playQueue == null) { + return; + } + + final boolean onPlaybackInitial = currentItem == null; + final boolean hasPlayQueueItemChanged = currentItem != item; + + final int currentPlayQueueIndex = playQueue.indexOf(item); + final int currentPlaylistIndex = simpleExoPlayer.getCurrentWindowIndex(); + final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); + + // If nothing to synchronize + if (!hasPlayQueueItemChanged) { + return; + } + currentItem = item; + + // Check if on wrong window + if (currentPlayQueueIndex != playQueue.getIndex()) { + Log.e(TAG, "Playback - Play Queue may be desynchronized: item " + + "index=[" + currentPlayQueueIndex + "], " + + "queue index=[" + playQueue.getIndex() + "]"); + + // Check if bad seek position + } else if ((currentPlaylistSize > 0 && currentPlayQueueIndex >= currentPlaylistSize) + || currentPlayQueueIndex < 0) { + Log.e(TAG, "Playback - Trying to seek to invalid " + + "index=[" + currentPlayQueueIndex + "] with " + + "playlist length=[" + currentPlaylistSize + "]"); + + } else if (currentPlaylistIndex != currentPlayQueueIndex || onPlaybackInitial + || !isPlaying()) { + if (DEBUG) { + Log.d(TAG, "Playback - Rewinding to correct " + + "index=[" + currentPlayQueueIndex + "], " + + "from=[" + currentPlaylistIndex + "], " + + "size=[" + currentPlaylistSize + "]."); + } + + if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { + simpleExoPlayer.seekTo(currentPlayQueueIndex, item.getRecoveryPosition()); + playQueue.unsetRecovery(currentPlayQueueIndex); + } else { + simpleExoPlayer.seekToDefaultPosition(currentPlayQueueIndex); + } + } + } + + protected void onMetadataChanged(@NonNull final MediaSourceTag tag) { + final StreamInfo info = tag.getMetadata(); + if (DEBUG) { + Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); + } + + initThumbnail(info.getThumbnailUrl()); + registerView(); + } + + @Override + public void onPlaybackShutdown() { + if (DEBUG) { + Log.d(TAG, "Shutting down..."); + } + destroy(); + } + + /*////////////////////////////////////////////////////////////////////////// + // General Player + //////////////////////////////////////////////////////////////////////////*/ + + public void showStreamError(final Exception exception) { + exception.printStackTrace(); + + if (errorToast == null) { + errorToast = Toast + .makeText(context, R.string.player_stream_failure, Toast.LENGTH_SHORT); + errorToast.show(); + } + } + + public void showRecoverableError(final Exception exception) { + exception.printStackTrace(); + + if (errorToast == null) { + errorToast = Toast + .makeText(context, R.string.player_recoverable_failure, Toast.LENGTH_SHORT); + errorToast.show(); + } + } + + public void showUnrecoverableError(final Exception exception) { + exception.printStackTrace(); + + if (errorToast != null) { + errorToast.cancel(); + } + errorToast = Toast + .makeText(context, R.string.player_unrecoverable_failure, Toast.LENGTH_SHORT); + errorToast.show(); + } + + public void onPrepared(final boolean playWhenReady) { + if (DEBUG) { + Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); + } + if (playWhenReady) { + audioReactor.requestAudioFocus(); + } + changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); + } + + public void onPlay() { + if (DEBUG) { + Log.d(TAG, "onPlay() called"); + } + if (audioReactor == null || playQueue == null || simpleExoPlayer == null) { + return; + } + + audioReactor.requestAudioFocus(); + + if (getCurrentState() == STATE_COMPLETED) { + if (playQueue.getIndex() == 0) { + seekToDefault(); + } else { + playQueue.setIndex(0); + } + } + + simpleExoPlayer.setPlayWhenReady(true); + } + + public void onPause() { + if (DEBUG) { + Log.d(TAG, "onPause() called"); + } + if (audioReactor == null || simpleExoPlayer == null) { + return; + } + + audioReactor.abandonAudioFocus(); + simpleExoPlayer.setPlayWhenReady(false); + } + + public void onPlayPause() { + if (DEBUG) { + Log.d(TAG, "onPlayPause() called"); + } + + if (isPlaying()) { + onPause(); + } else { + onPlay(); + } + } + + public void onFastRewind() { + if (DEBUG) { + Log.d(TAG, "onFastRewind() called"); + } + seekBy(-getSeekDuration()); + triggerProgressUpdate(); + } + + public void onFastForward() { + if (DEBUG) { + Log.d(TAG, "onFastForward() called"); + } + seekBy(getSeekDuration()); + triggerProgressUpdate(); + } + + private int getSeekDuration() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String key = context.getString(R.string.seek_duration_key); + final String value = prefs + .getString(key, context.getString(R.string.seek_duration_default_value)); + return Integer.parseInt(value); + } + + public void onPlayPrevious() { + if (simpleExoPlayer == null || playQueue == null) { + return; + } + 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.*/ + if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS + || playQueue.getIndex() == 0) { + seekToDefault(); + playQueue.offsetIndex(0); + } else { + savePlaybackState(); + playQueue.offsetIndex(-1); + } + } + + public void onPlayNext() { + if (playQueue == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "onPlayNext() called"); + } + + savePlaybackState(); + playQueue.offsetIndex(+1); + } + + public void onSelected(final PlayQueueItem item) { + if (playQueue == null || simpleExoPlayer == null) { + return; + } + + final int index = playQueue.indexOf(item); + if (index == -1) { + return; + } + + if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentWindowIndex() == index) { + seekToDefault(); + } else { + savePlaybackState(); + } + playQueue.setIndex(index); + } + + public void seekTo(final long positionMillis) { + if (DEBUG) { + Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); + } + if (simpleExoPlayer != null) { + simpleExoPlayer.seekTo(positionMillis); + } + } + + public void seekBy(final long offsetMillis) { + if (DEBUG) { + Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]"); + } + seekTo(simpleExoPlayer.getCurrentPosition() + offsetMillis); + } + + public boolean isCurrentWindowValid() { + return simpleExoPlayer != null && simpleExoPlayer.getDuration() >= 0 + && simpleExoPlayer.getCurrentPosition() >= 0; + } + + public void seekToDefault() { + if (simpleExoPlayer != null) { + simpleExoPlayer.seekToDefaultPosition(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void registerView() { + if (currentMetadata == null) { + return; + } + final StreamInfo currentInfo = currentMetadata.getMetadata(); + final Disposable viewRegister = recordManager.onViewed(currentInfo).onErrorComplete() + .subscribe( + ignored -> { /* successful */ }, + error -> Log.e(TAG, "Player onViewed() failure: ", error) + ); + databaseUpdateReactor.add(viewRegister); + } + + protected void reload() { + if (playbackManager != null) { + playbackManager.dispose(); + } + + if (playQueue != null) { + playbackManager = new MediaSourceManager(this, playQueue); + } + } + + private void savePlaybackState(final StreamInfo info, final long progress) { + if (info == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "savePlaybackState() called"); + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { + final Disposable stateSaver = recordManager.saveStreamState(info, progress) + .observeOn(mainThread()) + .doOnError((e) -> { + if (DEBUG) { + e.printStackTrace(); + } + }) + .onErrorComplete() + .subscribe(); + databaseUpdateReactor.add(stateSaver); + } + } + + private void resetPlaybackState(final PlayQueueItem queueItem) { + if (queueItem == null) { + return; + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { + final Disposable stateSaver = queueItem.getStream() + .flatMapCompletable(info -> recordManager.saveStreamState(info, 0)) + .observeOn(mainThread()) + .doOnError((e) -> { + if (DEBUG) { + e.printStackTrace(); + } + }) + .onErrorComplete() + .subscribe(); + databaseUpdateReactor.add(stateSaver); + } + } + + public void resetPlaybackState(final StreamInfo info) { + savePlaybackState(info, 0); + } + + public void savePlaybackState() { + if (simpleExoPlayer == null || currentMetadata == null) { + return; + } + final StreamInfo currentInfo = currentMetadata.getMetadata(); + savePlaybackState(currentInfo, simpleExoPlayer.getCurrentPosition()); + } + + private void maybeUpdateCurrentMetadata() { + if (simpleExoPlayer == null) { + return; + } + + final MediaSourceTag metadata; + try { + metadata = (MediaSourceTag) simpleExoPlayer.getCurrentTag(); + } catch (IndexOutOfBoundsException | ClassCastException error) { + if (DEBUG) { + Log.d(TAG, "Could not update metadata: " + error.getMessage()); + error.printStackTrace(); + } + return; + } + + if (metadata == null) { + return; + } + maybeAutoQueueNextStream(metadata); + + if (currentMetadata == metadata) { + return; + } + currentMetadata = metadata; + onMetadataChanged(metadata); + } + + private void maybeAutoQueueNextStream(@NonNull final MediaSourceTag metadata) { + if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1 + || getRepeatMode() != Player.REPEAT_MODE_OFF + || !PlayerHelper.isAutoQueueEnabled(context)) { + return; + } + // auto queue when starting playback on the last item when not repeating + final PlayQueue autoQueue = PlayerHelper.autoQueueOf(metadata.getMetadata(), + playQueue.getStreams()); + if (autoQueue != null) { + playQueue.append(autoQueue.getStreams()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Getters and Setters + //////////////////////////////////////////////////////////////////////////*/ + + public SimpleExoPlayer getPlayer() { + return simpleExoPlayer; + } + + public AudioReactor getAudioReactor() { + return audioReactor; + } + + public int getCurrentState() { + return currentState; + } + + @Nullable + public MediaSourceTag getCurrentMetadata() { + return currentMetadata; + } + + @NonNull + public String getVideoUrl() { + return currentMetadata == null + ? context.getString(R.string.unknown_content) + : currentMetadata.getMetadata().getUrl(); + } + + @NonNull + public String getVideoTitle() { + return currentMetadata == null + ? context.getString(R.string.unknown_content) + : currentMetadata.getMetadata().getName(); + } + + @NonNull + public String getUploaderName() { + return currentMetadata == null + ? context.getString(R.string.unknown_content) + : currentMetadata.getMetadata().getUploaderName(); + } + + @Nullable + public Bitmap getThumbnail() { + return currentThumbnail == null + ? BitmapFactory.decodeResource(context.getResources(), R.drawable.dummy_thumbnail) + : currentThumbnail; + } + + /** + * Checks if the current playback is a livestream AND is playing at or beyond the live edge. + * + * @return whether the livestream is playing at or beyond the edge + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean isLiveEdge() { + if (simpleExoPlayer == null || !isLive()) { + return false; + } + + final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); + final int currentWindowIndex = simpleExoPlayer.getCurrentWindowIndex(); + if (currentTimeline.isEmpty() || currentWindowIndex < 0 + || currentWindowIndex >= currentTimeline.getWindowCount()) { + return false; + } + + Timeline.Window timelineWindow = new Timeline.Window(); + currentTimeline.getWindow(currentWindowIndex, timelineWindow); + return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition(); + } + + public boolean isLive() { + if (simpleExoPlayer == null) { + return false; + } + try { + return simpleExoPlayer.isCurrentWindowDynamic(); + } catch (@NonNull IndexOutOfBoundsException e) { + // Why would this even happen =( + // But lets log it anyway. Save is save + if (DEBUG) { + Log.d(TAG, "Could not update metadata: " + e.getMessage()); + e.printStackTrace(); + } + return false; + } + } + + public boolean isPlaying() { + return simpleExoPlayer != null && simpleExoPlayer.isPlaying(); + } + + @Player.RepeatMode + public int getRepeatMode() { + return simpleExoPlayer == null + ? Player.REPEAT_MODE_OFF + : simpleExoPlayer.getRepeatMode(); + } + + public void setRepeatMode(@Player.RepeatMode final int repeatMode) { + if (simpleExoPlayer != null) { + simpleExoPlayer.setRepeatMode(repeatMode); + } + } + + public float getPlaybackSpeed() { + return getPlaybackParameters().speed; + } + + public void setPlaybackSpeed(final float speed) { + setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()); + } + + public float getPlaybackPitch() { + return getPlaybackParameters().pitch; + } + + public boolean getPlaybackSkipSilence() { + return getPlaybackParameters().skipSilence; + } + + public PlaybackParameters getPlaybackParameters() { + if (simpleExoPlayer == null) { + return PlaybackParameters.DEFAULT; + } + final PlaybackParameters parameters = simpleExoPlayer.getPlaybackParameters(); + return parameters == null ? PlaybackParameters.DEFAULT : parameters; + } + + /** + * Sets the playback parameters of the player, and also saves them to shared preferences. + * Speed and pitch are rounded up to 2 decimal places before being used or saved. + * @param speed the playback speed, will be rounded to up to 2 decimal places + * @param pitch the playback pitch, will be rounded to up to 2 decimal places + * @param skipSilence skip silence during playback + */ + public void setPlaybackParameters(final float speed, final float pitch, + final boolean skipSilence) { + final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f; + final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f; + + savePlaybackParametersToPreferences(roundedSpeed, roundedPitch, skipSilence); + simpleExoPlayer.setPlaybackParameters( + new PlaybackParameters(roundedSpeed, roundedPitch, skipSilence)); + } + + private void savePlaybackParametersToPreferences(final float speed, final float pitch, + final boolean skipSilence) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putFloat(context.getString(R.string.playback_speed_key), speed) + .putFloat(context.getString(R.string.playback_pitch_key), pitch) + .putBoolean(context.getString(R.string.playback_skip_silence_key), skipSilence) + .apply(); + } + + public PlayQueue getPlayQueue() { + return playQueue; + } + + public PlayQueueAdapter getPlayQueueAdapter() { + return playQueueAdapter; + } + + public boolean isPrepared() { + return isPrepared; + } + + public boolean isProgressLoopRunning() { + return progressUpdateReactor.get() != null; + } + + public void setRecovery() { + if (playQueue == null || simpleExoPlayer == null) { + return; + } + + final int queuePos = playQueue.getIndex(); + final long windowPos = simpleExoPlayer.getCurrentPosition(); + + if (windowPos > 0 && windowPos <= simpleExoPlayer.getDuration()) { + setRecovery(queuePos, windowPos); + } + } + + public void setRecovery(final int queuePos, final long windowPos) { + if (playQueue.size() <= queuePos) { + return; + } + + if (DEBUG) { + Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); + } + playQueue.setRecovery(queuePos, windowPos); + } + + public boolean gotDestroyed() { + return simpleExoPlayer == null; + } + + private boolean isPlaybackResumeEnabled() { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) + && prefs.getBoolean(context.getString(R.string.enable_playback_resume_key), true); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/MainVideoPlayer.java b/app/src/main/java/org/schabi/newpipelegacy/player/MainVideoPlayer.java new file mode 100644 index 000000000..66951f794 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/MainVideoPlayer.java @@ -0,0 +1,1472 @@ +/* + * Copyright 2017 Mauricio Colli + * Copyright 2019 Eltex ltd + * MainVideoPlayer.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.player; + +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.database.ContentObserver; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.DisplayCutout; +import android.view.GestureDetector; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.app.ActivityCompat; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.text.CaptionStyleCompat; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipelegacy.player.helper.PlaybackParameterDialog; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemBuilder; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemHolder; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipelegacy.player.resolver.MediaSourceTag; +import org.schabi.newpipelegacy.player.resolver.VideoPlaybackResolver; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.AnimationUtils; +import org.schabi.newpipelegacy.util.KoreUtil; +import org.schabi.newpipelegacy.util.ListHelper; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.PermissionHelper; +import org.schabi.newpipelegacy.util.ShareUtils; +import org.schabi.newpipelegacy.util.StateSaver; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.FocusOverlayView; + +import java.util.List; +import java.util.Queue; +import java.util.UUID; + +import static org.schabi.newpipelegacy.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipelegacy.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipelegacy.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; +import static org.schabi.newpipelegacy.player.VideoPlayer.DPAD_CONTROLS_HIDE_TIME; +import static org.schabi.newpipelegacy.util.AnimationUtils.Type.SCALE_AND_ALPHA; +import static org.schabi.newpipelegacy.util.AnimationUtils.Type.SLIDE_AND_ALPHA; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateRotation; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; +import static org.schabi.newpipelegacy.util.StateSaver.KEY_SAVED_STATE; + +/** + * Activity Player implementing {@link VideoPlayer}. + * + * @author mauriciocolli + */ +public final class MainVideoPlayer extends AppCompatActivity + implements StateSaver.WriteRead, PlaybackParameterDialog.Callback { + private static final String TAG = ".MainVideoPlayer"; + private static final boolean DEBUG = BasePlayer.DEBUG; + + private GestureDetector gestureDetector; + + private VideoPlayerImpl playerImpl; + + private SharedPreferences defaultPreferences; + + @Nullable + private PlayerState playerState; + private boolean isInMultiWindow; + private boolean isBackPressed; + + private ContentObserver rotationObserver; + + /*////////////////////////////////////////////////////////////////////////// + // Activity LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void onCreate(@Nullable final Bundle savedInstanceState) { + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceState); + if (DEBUG) { + Log.d(TAG, "onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + defaultPreferences = PreferenceManager.getDefaultSharedPreferences(this); + ThemeHelper.setTheme(this); + getWindow().setBackgroundDrawable(new ColorDrawable(Color.BLACK)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + getWindow().setStatusBarColor(Color.BLACK); + } + setVolumeControlStream(AudioManager.STREAM_MUSIC); + + WindowManager.LayoutParams lp = getWindow().getAttributes(); + lp.screenBrightness = PlayerHelper.getScreenBrightness(getApplicationContext()); + getWindow().setAttributes(lp); + + hideSystemUi(); + setContentView(R.layout.activity_main_player); + + playerImpl = new VideoPlayerImpl(this); + playerImpl.setup(findViewById(android.R.id.content)); + + if (savedInstanceState != null && savedInstanceState.get(KEY_SAVED_STATE) != null) { + return; // We have saved states, stop here to restore it + } + + final Intent intent = getIntent(); + if (intent != null) { + playerImpl.handleIntent(intent); + } else { + Toast.makeText(this, R.string.general_error, Toast.LENGTH_SHORT).show(); + finish(); + } + + rotationObserver = new ContentObserver(new Handler()) { + @Override + public void onChange(final boolean selfChange) { + super.onChange(selfChange); + if (globalScreenOrientationLocked()) { + final String orientKey = getString(R.string.last_orientation_landscape_key); + + final boolean lastOrientationWasLandscape = defaultPreferences + .getBoolean(orientKey, AndroidTvUtils.isTv(getApplicationContext())); + setLandscape(lastOrientationWasLandscape); + } else { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); + } + } + }; + + getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), + false, rotationObserver); + + if (AndroidTvUtils.isTv(this)) { + FocusOverlayView.setupFocusObserver(this); + } + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle bundle) { + if (DEBUG) { + Log.d(TAG, "onRestoreInstanceState() called"); + } + super.onRestoreInstanceState(bundle); + StateSaver.tryToRestore(bundle, this); + } + + @Override + protected void onNewIntent(final Intent intent) { + if (DEBUG) { + Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]"); + } + super.onNewIntent(intent); + if (intent != null) { + playerState = null; + playerImpl.handleIntent(intent); + } + } + + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent event) { + switch (event.getKeyCode()) { + default: + break; + case KeyEvent.KEYCODE_BACK: + if (AndroidTvUtils.isTv(getApplicationContext()) + && playerImpl.isControlsVisible()) { + playerImpl.hideControls(0, 0); + hideSystemUi(); + return true; + } + break; + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_DPAD_RIGHT: + case KeyEvent.KEYCODE_DPAD_CENTER: + View playerRoot = playerImpl.getRootView(); + View controls = playerImpl.getControlsRoot(); + if (playerRoot.hasFocus() && !controls.hasFocus()) { + // do not interfere with focus in playlist etc. + return super.onKeyDown(keyCode, event); + } + + if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) { + return true; + } + + if (!playerImpl.isControlsVisible()) { + playerImpl.playPauseButton.requestFocus(); + playerImpl.showControlsThenHide(); + showSystemUi(); + return true; + } else { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); + } + break; + } + + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onResume() { + if (DEBUG) { + Log.d(TAG, "onResume() called"); + } + assureCorrectAppLanguage(this); + super.onResume(); + + if (globalScreenOrientationLocked()) { + final String orientKey = getString(R.string.last_orientation_landscape_key); + + boolean lastOrientationWasLandscape = defaultPreferences + .getBoolean(orientKey, AndroidTvUtils.isTv(getApplicationContext())); + 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 + // prepared should be ignored. + if (isInMultiWindow) { + return; + } + isInMultiWindow = isInMultiWindow(); + + if (playerState != null) { + playerImpl.setPlaybackQuality(playerState.getPlaybackQuality()); + playerImpl.initPlayback(playerState.getPlayQueue(), playerState.getRepeatMode(), + playerState.getPlaybackSpeed(), playerState.getPlaybackPitch(), + playerState.isPlaybackSkipSilence(), playerState.wasPlaying(), + playerImpl.isMuted()); + } + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + assureCorrectAppLanguage(this); + + if (playerImpl.isSomePopupMenuVisible()) { + playerImpl.getQualityPopupMenu().dismiss(); + playerImpl.getPlaybackSpeedPopupMenu().dismiss(); + } + } + + @Override + public void onBackPressed() { + super.onBackPressed(); + isBackPressed = true; + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + if (DEBUG) { + Log.d(TAG, "onSaveInstanceState() called"); + } + super.onSaveInstanceState(outState); + if (playerImpl == null) { + return; + } + + playerImpl.setRecovery(); + if (!playerImpl.gotDestroyed()) { + playerState = createPlayerState(); + } + StateSaver.tryToSave(isChangingConfigurations(), null, outState, this); + } + + @Override + protected void onStop() { + if (DEBUG) { + Log.d(TAG, "onStop() called"); + } + super.onStop(); + PlayerHelper.setScreenBrightness(getApplicationContext(), + getWindow().getAttributes().screenBrightness); + + if (playerImpl == null) { + return; + } + if (!isBackPressed) { + playerImpl.minimize(); + } + playerState = createPlayerState(); + playerImpl.destroy(); + + if (rotationObserver != null) { + getContentResolver().unregisterContentObserver(rotationObserver); + } + + isInMultiWindow = false; + isBackPressed = false; + } + + @Override + protected void attachBaseContext(final Context newBase) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(newBase)); + } + + @Override + protected void onPause() { + playerImpl.savePlaybackState(); + super.onPause(); + } + + /*////////////////////////////////////////////////////////////////////////// + // 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"; + } + + @Override + public void writeTo(final Queue objectsToSave) { + if (objectsToSave == null) { + return; + } + objectsToSave.add(playerState); + } + + @Override + @SuppressWarnings("unchecked") + public void readFrom(@NonNull final Queue savedObjects) { + playerState = (PlayerState) savedObjects.poll(); + } + + /*////////////////////////////////////////////////////////////////////////// + // View + //////////////////////////////////////////////////////////////////////////*/ + + private void showSystemUi() { + if (DEBUG) { + Log.d(TAG, "showSystemUi() called"); + } + if (playerImpl != null && playerImpl.queueVisible) { + return; + } + + final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + @ColorInt final int systenUiColor = + ActivityCompat.getColor(getApplicationContext(), R.color.video_overlay_color); + getWindow().setStatusBarColor(systenUiColor); + getWindow().setNavigationBarColor(systenUiColor); + } + + getWindow().getDecorView().setSystemUiVisibility(visibility); + getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + private void hideSystemUi() { + if (DEBUG) { + Log.d(TAG, "hideSystemUi() called"); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } + getWindow().getDecorView().setSystemUiVisibility(visibility); + } + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + private void toggleOrientation() { + setLandscape(!isLandscape()); + defaultPreferences.edit() + .putBoolean(getString(R.string.last_orientation_landscape_key), !isLandscape()) + .apply(); + } + + private boolean isLandscape() { + return getResources().getDisplayMetrics().heightPixels + < getResources().getDisplayMetrics().widthPixels; + } + + private void setLandscape(final boolean v) { + setRequestedOrientation(v + ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + + private boolean globalScreenOrientationLocked() { + // 1: Screen orientation changes using accelerometer + // 0: Screen orientation is locked + return !(android.provider.Settings.System + .getInt(getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 1); + } + + protected void setRepeatModeButton(final ImageButton imageButton, final int repeatMode) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + imageButton.setImageResource(R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + imageButton.setImageResource(R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + imageButton.setImageResource(R.drawable.exo_controls_repeat_all); + break; + } + } + + protected void setShuffleButton(final ImageButton shuffleButton, final boolean shuffled) { + final int shuffleAlpha = shuffled ? 255 : 77; + shuffleButton.setImageAlpha(shuffleAlpha); + } + + protected void setMuteButton(final ImageButton muteButton, final boolean isMuted) { + muteButton.setImageDrawable(AppCompatResources.getDrawable(getApplicationContext(), isMuted + ? R.drawable.ic_volume_off_white_24dp : R.drawable.ic_volume_up_white_24dp)); + } + + + private boolean isInMultiWindow() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInMultiWindowMode(); + } + + //////////////////////////////////////////////////////////////////////////// + // Playback Parameters Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch, + final boolean playbackSkipSilence) { + if (playerImpl != null) { + playerImpl.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); + } + } + + /////////////////////////////////////////////////////////////////////////// + + @SuppressWarnings({"unused", "WeakerAccess"}) + private class VideoPlayerImpl extends VideoPlayer { + private static final float MAX_GESTURE_LENGTH = 0.75f; + + private TextView titleTextView; + private TextView channelTextView; + private RelativeLayout volumeRelativeLayout; + private ProgressBar volumeProgressBar; + private ImageView volumeImageView; + private RelativeLayout brightnessRelativeLayout; + private ProgressBar brightnessProgressBar; + private ImageView brightnessImageView; + private ImageButton queueButton; + private ImageButton repeatButton; + private ImageButton shuffleButton; + + private ImageButton playPauseButton; + private ImageButton playPreviousButton; + private ImageButton playNextButton; + private Button closeButton; + + private RelativeLayout queueLayout; + private ImageButton itemsListCloseButton; + private RecyclerView itemsList; + private ItemTouchHelper itemTouchHelper; + + private boolean queueVisible; + + private ImageButton moreOptionsButton; + private ImageButton kodiButton; + private ImageButton shareButton; + private ImageButton toggleOrientationButton; + private ImageButton switchPopupButton; + private ImageButton switchBackgroundButton; + private ImageButton muteButton; + + private RelativeLayout windowRootLayout; + private View secondaryControls; + + private int maxGestureLength; + + VideoPlayerImpl(final Context context) { + super("VideoPlayerImpl" + MainVideoPlayer.TAG, context); + } + + @Override + public void initViews(final View view) { + super.initViews(view); + this.titleTextView = view.findViewById(R.id.titleTextView); + this.channelTextView = view.findViewById(R.id.channelTextView); + this.volumeRelativeLayout = view.findViewById(R.id.volumeRelativeLayout); + this.volumeProgressBar = view.findViewById(R.id.volumeProgressBar); + this.volumeImageView = view.findViewById(R.id.volumeImageView); + this.brightnessRelativeLayout = view.findViewById(R.id.brightnessRelativeLayout); + this.brightnessProgressBar = view.findViewById(R.id.brightnessProgressBar); + this.brightnessImageView = view.findViewById(R.id.brightnessImageView); + this.queueButton = view.findViewById(R.id.queueButton); + this.repeatButton = view.findViewById(R.id.repeatButton); + this.shuffleButton = view.findViewById(R.id.shuffleButton); + + this.playPauseButton = view.findViewById(R.id.playPauseButton); + this.playPreviousButton = view.findViewById(R.id.playPreviousButton); + this.playNextButton = view.findViewById(R.id.playNextButton); + this.closeButton = view.findViewById(R.id.closeButton); + + this.moreOptionsButton = view.findViewById(R.id.moreOptionsButton); + this.secondaryControls = view.findViewById(R.id.secondaryControls); + this.kodiButton = view.findViewById(R.id.kodi); + this.shareButton = view.findViewById(R.id.share); + this.toggleOrientationButton = view.findViewById(R.id.toggleOrientation); + this.switchBackgroundButton = view.findViewById(R.id.switchBackground); + this.muteButton = view.findViewById(R.id.switchMute); + this.switchPopupButton = view.findViewById(R.id.switchPopup); + + this.queueLayout = findViewById(R.id.playQueuePanel); + this.itemsListCloseButton = findViewById(R.id.playQueueClose); + this.itemsList = findViewById(R.id.playQueue); + + titleTextView.setSelected(true); + channelTextView.setSelected(true); + + getRootView().setKeepScreenOn(true); + } + + @Override + protected void setupSubtitleView(@NonNull final SubtitleView view, + final float captionScale, + @NonNull final CaptionStyleCompat captionStyle) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); + final float captionRatioInverse = 20f + 4f * (1f - captionScale); + view.setFixedTextSize(TypedValue.COMPLEX_UNIT_PX, + (float) minimumLength / captionRatioInverse); + view.setApplyEmbeddedStyles(captionStyle.equals(CaptionStyleCompat.DEFAULT)); + view.setStyle(captionStyle); + } + + @Override + public void initListeners() { + super.initListeners(); + + PlayerGestureListener listener = new PlayerGestureListener(); + gestureDetector = new GestureDetector(context, listener); + gestureDetector.setIsLongpressEnabled(false); + getRootView().setOnTouchListener(listener); + + queueButton.setOnClickListener(this); + repeatButton.setOnClickListener(this); + shuffleButton.setOnClickListener(this); + + playPauseButton.setOnClickListener(this); + playPreviousButton.setOnClickListener(this); + playNextButton.setOnClickListener(this); + closeButton.setOnClickListener(this); + + moreOptionsButton.setOnClickListener(this); + kodiButton.setOnClickListener(this); + shareButton.setOnClickListener(this); + toggleOrientationButton.setOnClickListener(this); + switchBackgroundButton.setOnClickListener(this); + muteButton.setOnClickListener(this); + switchPopupButton.setOnClickListener(this); + + getRootView().addOnLayoutChangeListener((view, l, t, r, b, ol, ot, or, ob) -> { + if (l != ol || t != ot || r != or || b != ob) { + // Use smaller value to be consistent between screen orientations + // (and to make usage easier) + int width = r - l; + int height = b - t; + maxGestureLength = (int) (Math.min(width, height) * MAX_GESTURE_LENGTH); + + if (DEBUG) { + Log.d(TAG, "maxGestureLength = " + maxGestureLength); + } + + volumeProgressBar.setMax(maxGestureLength); + brightnessProgressBar.setMax(maxGestureLength); + + setInitialGestureValues(); + } + }); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + queueLayout.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { + @Override + public WindowInsets onApplyWindowInsets(final View view, + final WindowInsets windowInsets) { + final DisplayCutout cutout = windowInsets.getDisplayCutout(); + if (cutout != null) { + view.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), + cutout.getSafeInsetRight(), cutout.getSafeInsetBottom()); + } + return windowInsets; + } + }); + } + } + + public void minimize() { + switch (PlayerHelper.getMinimizeOnExitAction(context)) { + case PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND: + onPlayBackgroundButtonClicked(); + break; + case PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP: + onFullScreenButtonClicked(); + break; + case PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE: + default: + // No action + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Video Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onRepeatModeChanged(final int i) { + super.onRepeatModeChanged(i); + updatePlaybackButtons(); + } + + @Override + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlaybackButtons(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected void onMetadataChanged(@NonNull final MediaSourceTag tag) { + super.onMetadataChanged(tag); + + // show kodi button if it supports the current service and it is enabled in settings + final boolean showKodiButton = + KoreUtil.isServiceSupportedByKore(tag.getMetadata().getServiceId()) + && PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); + kodiButton.setVisibility(showKodiButton ? View.VISIBLE : View.GONE); + + titleTextView.setText(tag.getMetadata().getName()); + channelTextView.setText(tag.getMetadata().getUploaderName()); + } + + @Override + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); + finish(); + } + + public void onKodiShare() { + onPause(); + try { + NavigationHelper.playWithKore(context, Uri.parse(playerImpl.getVideoUrl())); + } catch (Exception e) { + if (DEBUG) { + Log.i(TAG, "Failed to start kore", e); + } + KoreUtil.showInstallKoreDialog(context); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Player Overrides + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onFullScreenButtonClicked() { + super.onFullScreenButtonClicked(); + + if (DEBUG) { + Log.d(TAG, "onFullScreenButtonClicked() called"); + } + if (simpleExoPlayer == null) { + return; + } + + if (!PermissionHelper.isPopupEnabled(context)) { + PermissionHelper.showPopupEnablementToast(context); + return; + } + + setRecovery(); + final Intent intent = NavigationHelper.getPlayerIntent( + context, + PopupVideoPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackSkipSilence(), + this.getPlaybackQuality(), + false, + !isPlaying(), + isMuted() + ); + context.startService(intent); + + ((View) getControlAnimationView().getParent()).setVisibility(View.GONE); + destroy(); + finish(); + } + + public void onPlayBackgroundButtonClicked() { + if (DEBUG) { + Log.d(TAG, "onPlayBackgroundButtonClicked() called"); + } + if (playerImpl.getPlayer() == null) { + return; + } + + setRecovery(); + final Intent intent = NavigationHelper.getPlayerIntent( + context, + BackgroundPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackSkipSilence(), + this.getPlaybackQuality(), + false, + !isPlaying(), + isMuted() + ); + context.startService(intent); + + ((View) getControlAnimationView().getParent()).setVisibility(View.GONE); + destroy(); + finish(); + } + + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + setMuteButton(muteButton, playerImpl.isMuted()); + } + + + @Override + public void onClick(final View v) { + super.onClick(v); + if (v.getId() == playPauseButton.getId()) { + onPlayPause(); + } else if (v.getId() == playPreviousButton.getId()) { + onPlayPrevious(); + } else if (v.getId() == playNextButton.getId()) { + onPlayNext(); + } else if (v.getId() == queueButton.getId()) { + onQueueClicked(); + return; + } else if (v.getId() == repeatButton.getId()) { + onRepeatClicked(); + return; + } else if (v.getId() == shuffleButton.getId()) { + onShuffleClicked(); + return; + } else if (v.getId() == moreOptionsButton.getId()) { + onMoreOptionsClicked(); + } else if (v.getId() == shareButton.getId()) { + onShareClicked(); + } else if (v.getId() == toggleOrientationButton.getId()) { + onScreenRotationClicked(); + } else if (v.getId() == switchPopupButton.getId()) { + onFullScreenButtonClicked(); + } else if (v.getId() == switchBackgroundButton.getId()) { + onPlayBackgroundButtonClicked(); + } else if (v.getId() == muteButton.getId()) { + onMuteUnmuteButtonClicked(); + } else if (v.getId() == closeButton.getId()) { + onPlaybackShutdown(); + return; + } else if (v.getId() == kodiButton.getId()) { + onKodiShare(); + } + + if (getCurrentState() != STATE_COMPLETED) { + getControlsVisibilityHandler().removeCallbacksAndMessages(null); + animateView(getControlsRoot(), true, DEFAULT_CONTROLS_DURATION, 0, () -> { + if (getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible()) { + safeHideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + }); + } + } + + private void onQueueClicked() { + queueVisible = true; + hideSystemUi(); + + buildQueue(); + updatePlaybackButtons(); + + getControlsRoot().setVisibility(View.INVISIBLE); + animateView(queueLayout, SLIDE_AND_ALPHA, true, DEFAULT_CONTROLS_DURATION); + + itemsList.scrollToPosition(playQueue.getIndex()); + } + + private void onQueueClosed() { + animateView(queueLayout, SLIDE_AND_ALPHA, false, DEFAULT_CONTROLS_DURATION); + queueVisible = false; + } + + private void onMoreOptionsClicked() { + if (DEBUG) { + Log.d(TAG, "onMoreOptionsClicked() called"); + } + + final boolean isMoreControlsVisible + = secondaryControls.getVisibility() == View.VISIBLE; + + animateRotation(moreOptionsButton, DEFAULT_CONTROLS_DURATION, + isMoreControlsVisible ? 0 : 180); + animateView(secondaryControls, SLIDE_AND_ALPHA, !isMoreControlsVisible, + DEFAULT_CONTROLS_DURATION); + showControls(DEFAULT_CONTROLS_DURATION); + setMuteButton(muteButton, playerImpl.isMuted()); + } + + private void onShareClicked() { + // share video at the current time (youtube.com/watch?v=ID&t=SECONDS) + ShareUtils.shareUrl(MainVideoPlayer.this, playerImpl.getVideoTitle(), + playerImpl.getVideoUrl() + + "&t=" + playerImpl.getPlaybackSeekBar().getProgress() / 1000); + } + + private void onScreenRotationClicked() { + if (DEBUG) { + Log.d(TAG, "onScreenRotationClicked() called"); + } + toggleOrientation(); + showControlsThenHide(); + } + + @Override + public void onPlaybackSpeedClicked() { + PlaybackParameterDialog + .newInstance(getPlaybackSpeed(), getPlaybackPitch(), getPlaybackSkipSilence()) + .show(getSupportFragmentManager(), TAG); + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + super.onStopTrackingTouch(seekBar); + if (wasPlaying()) { + showControlsThenHide(); + } + } + + @Override + public void onDismiss(final PopupMenu menu) { + super.onDismiss(menu); + if (isPlaying()) { + hideControls(DEFAULT_CONTROLS_DURATION, 0); + } + hideSystemUi(); + } + + @Override + protected int nextResizeMode(final int currentResizeMode) { + final int newResizeMode; + switch (currentResizeMode) { + case AspectRatioFrameLayout.RESIZE_MODE_FIT: + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; + break; + case AspectRatioFrameLayout.RESIZE_MODE_FILL: + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; + break; + default: + newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; + break; + } + + storeResizeMode(newResizeMode); + return newResizeMode; + } + + private void storeResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { + defaultPreferences.edit() + .putInt(getString(R.string.last_resize_mode), resizeMode) + .apply(); + } + + @Override + protected VideoPlaybackResolver.QualityResolver getQualityResolver() { + return new VideoPlaybackResolver.QualityResolver() { + @Override + public int getDefaultResolutionIndex(final List sortedVideos) { + return ListHelper.getDefaultResolutionIndex(context, sortedVideos); + } + + @Override + public int getOverrideResolutionIndex(final List sortedVideos, + final String playbackQuality) { + return ListHelper.getResolutionIndex(context, sortedVideos, playbackQuality); + } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // States + //////////////////////////////////////////////////////////////////////////*/ + + private void animatePlayButtons(final boolean show, final int duration) { + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + animateView(playPreviousButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + animateView(playNextButton, AnimationUtils.Type.SCALE_AND_ALPHA, show, duration); + } + + @Override + public void onBlocked() { + super.onBlocked(); + playPauseButton.setImageResource(R.drawable.ic_pause_white_24dp); + animatePlayButtons(false, 100); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); + getRootView().setKeepScreenOn(true); + } + + @Override + public void onBuffering() { + super.onBuffering(); + getRootView().setKeepScreenOn(true); + } + + @Override + public void onPlaying() { + super.onPlaying(); + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> { + playPauseButton.setImageResource(R.drawable.ic_pause_white_24dp); + animatePlayButtons(true, 200); + playPauseButton.requestFocus(); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); + }); + + getRootView().setKeepScreenOn(true); + } + + @Override + public void onPaused() { + super.onPaused(); + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 80, 0, () -> { + playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + animatePlayButtons(true, 200); + playPauseButton.requestFocus(); + animateView(closeButton, false, DEFAULT_CONTROLS_DURATION); + }); + + showSystemUi(); + getRootView().setKeepScreenOn(false); + } + + @Override + public void onPausedSeek() { + super.onPausedSeek(); + animatePlayButtons(false, 100); + getRootView().setKeepScreenOn(true); + } + + + @Override + public void onCompleted() { + animateView(playPauseButton, AnimationUtils.Type.SCALE_AND_ALPHA, false, 0, 0, () -> { + playPauseButton.setImageResource(R.drawable.ic_replay_white_24dp); + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); + animateView(closeButton, true, DEFAULT_CONTROLS_DURATION); + }); + getRootView().setKeepScreenOn(false); + super.onCompleted(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void setInitialGestureValues() { + if (getAudioReactor() != null) { + final float currentVolumeNormalized + = (float) getAudioReactor().getVolume() / getAudioReactor().getMaxVolume(); + volumeProgressBar.setProgress( + (int) (volumeProgressBar.getMax() * currentVolumeNormalized)); + } + + float screenBrightness = getWindow().getAttributes().screenBrightness; + if (screenBrightness < 0) { + screenBrightness = Settings.System.getInt(getContentResolver(), + Settings.System.SCREEN_BRIGHTNESS, 0) / 255.0f; + } + + brightnessProgressBar.setProgress( + (int) (brightnessProgressBar.getMax() * screenBrightness)); + + if (DEBUG) { + Log.d(TAG, "setInitialGestureValues: volumeProgressBar.getProgress() [" + + volumeProgressBar.getProgress() + "] " + + "brightnessProgressBar.getProgress() [" + + brightnessProgressBar.getProgress() + "]"); + } + } + + @Override + public void showControlsThenHide() { + if (queueVisible) { + return; + } + + super.showControlsThenHide(); + } + + @Override + public void showControls(final long duration) { + if (queueVisible) { + return; + } + + super.showControls(duration); + } + + @Override + public void safeHideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "safeHideControls() called with: delay = [" + delay + "]"); + } + + View controlsRoot = getControlsRoot(); + if (controlsRoot.isInTouchMode()) { + getControlsVisibilityHandler().removeCallbacksAndMessages(null); + getControlsVisibilityHandler().postDelayed(() -> + animateView(controlsRoot, false, duration, 0, + MainVideoPlayer.this::hideSystemUi), delay); + } + } + + @Override + public void hideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); + } + getControlsVisibilityHandler().removeCallbacksAndMessages(null); + getControlsVisibilityHandler().postDelayed(() -> + animateView(getControlsRoot(), false, duration, 0, + MainVideoPlayer.this::hideSystemUi), + /*delayMillis=*/delay + ); + } + + private void updatePlaybackButtons() { + if (repeatButton == null || shuffleButton == null + || simpleExoPlayer == null || playQueue == null) { + return; + } + + setRepeatModeButton(repeatButton, getRepeatMode()); + setShuffleButton(shuffleButton, playQueue.isShuffled()); + } + + private void buildQueue() { + itemsList.setAdapter(playQueueAdapter); + itemsList.setClickable(true); + itemsList.setLongClickable(true); + + itemsList.clearOnScrollListeners(); + itemsList.addOnScrollListener(getQueueScrollListener()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + playQueueAdapter.setSelectedListener(getOnSelectedListener()); + + itemsListCloseButton.setOnClickListener(view -> onQueueClosed()); + } + + private OnScrollBelowItemsListener getQueueScrollListener() { + return new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(final RecyclerView recyclerView) { + if (playQueue != null && !playQueue.isComplete()) { + playQueue.fetch(); + } else if (itemsList != null) { + itemsList.clearOnScrollListeners(); + } + } + }; + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new PlayQueueItemTouchCallback() { + @Override + public void onMove(final int sourceIndex, final int targetIndex) { + if (playQueue != null) { + playQueue.move(sourceIndex, targetIndex); + } + } + + @Override + public void onSwiped(final int index) { + if (index != -1) { + playQueue.remove(index); + } + } + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(final PlayQueueItem item, final View view) { + onSelected(item); + } + + @Override + public void held(final PlayQueueItem item, final View view) { + final int index = playQueue.indexOf(item); + if (index != -1) { + playQueue.remove(index); + } + } + + @Override + public void onStartDrag(final PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) { + itemTouchHelper.startDrag(viewHolder); + } + } + }; + } + + /////////////////////////////////////////////////////////////////////////// + // Getters + /////////////////////////////////////////////////////////////////////////// + + public TextView getTitleTextView() { + return titleTextView; + } + + public TextView getChannelTextView() { + return channelTextView; + } + + public RelativeLayout getVolumeRelativeLayout() { + return volumeRelativeLayout; + } + + public ProgressBar getVolumeProgressBar() { + return volumeProgressBar; + } + + public ImageView getVolumeImageView() { + return volumeImageView; + } + + public RelativeLayout getBrightnessRelativeLayout() { + return brightnessRelativeLayout; + } + + public ProgressBar getBrightnessProgressBar() { + return brightnessProgressBar; + } + + public ImageView getBrightnessImageView() { + return brightnessImageView; + } + + public ImageButton getRepeatButton() { + return repeatButton; + } + + public ImageButton getMuteButton() { + return muteButton; + } + + public ImageButton getPlayPauseButton() { + return playPauseButton; + } + + public int getMaxGestureLength() { + return maxGestureLength; + } + } + + private class PlayerGestureListener extends GestureDetector.SimpleOnGestureListener + implements View.OnTouchListener { + private static final int MOVEMENT_THRESHOLD = 40; + + private final boolean isVolumeGestureEnabled = PlayerHelper + .isVolumeGestureEnabled(getApplicationContext()); + private final boolean isBrightnessGestureEnabled = PlayerHelper + .isBrightnessGestureEnabled(getApplicationContext()); + + private final int maxVolume = playerImpl.getAudioReactor().getMaxVolume(); + + private boolean isMoving; + + @Override + public boolean onDoubleTap(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onDoubleTap() called with: " + + "e = [" + e + "], " + + "rawXy = " + e.getRawX() + ", " + e.getRawY() + ", " + + "xy = " + e.getX() + ", " + e.getY()); + } + + if (e.getX() > playerImpl.getRootView().getWidth() * 2 / 3) { + playerImpl.onFastForward(); + } else if (e.getX() < playerImpl.getRootView().getWidth() / 3) { + playerImpl.onFastRewind(); + } else { + playerImpl.getPlayPauseButton().performClick(); + } + + return true; + } + + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); + } + if (playerImpl.getCurrentState() == BasePlayer.STATE_BLOCKED) { + return true; + } + + if (playerImpl.isControlsVisible()) { + playerImpl.hideControls(150, 0); + } else { + playerImpl.playPauseButton.requestFocus(); + playerImpl.showControlsThenHide(); + showSystemUi(); + } + + return true; + } + + @Override + public boolean onDown(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onDown() called with: e = [" + e + "]"); + } + + return super.onDown(e); + } + + @Override + public boolean onScroll(final MotionEvent initialEvent, final MotionEvent movingEvent, + final float distanceX, final float distanceY) { + if (!isVolumeGestureEnabled && !isBrightnessGestureEnabled) { + return false; + } + + final boolean isTouchingStatusBar = initialEvent.getY() < getStatusBarHeight(); + final boolean isTouchingNavigationBar = initialEvent.getY() + > playerImpl.getRootView().getHeight() - getNavigationBarHeight(); + if (isTouchingStatusBar || isTouchingNavigationBar) { + return false; + } + +// if (DEBUG) { +// Log.d(TAG, "MainVideoPlayer.onScroll = " + +// "e1.getRaw = [" + initialEvent.getRawX() + ", " +// + initialEvent.getRawY() + "], " + +// "e2.getRaw = [" + movingEvent.getRawX() + ", " +// + movingEvent.getRawY() + "], " + +// "distanceXy = [" + distanceX + ", " + distanceY + "]"); +// } + + final boolean insideThreshold + = Math.abs(movingEvent.getY() - initialEvent.getY()) <= MOVEMENT_THRESHOLD; + if (!isMoving && (insideThreshold || Math.abs(distanceX) > Math.abs(distanceY)) + || playerImpl.getCurrentState() == BasePlayer.STATE_COMPLETED) { + return false; + } + + isMoving = true; + + 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(); + int currentVolume = (int) (maxVolume * currentProgressPercent); + playerImpl.getAudioReactor().setVolume(currentVolume); + + if (DEBUG) { + Log.d(TAG, "onScroll().volumeControl, currentVolume = " + currentVolume); + } + + final int resId = currentProgressPercent <= 0 + ? R.drawable.ic_volume_off_white_24dp + : currentProgressPercent < 0.25 + ? R.drawable.ic_volume_mute_white_24dp + : currentProgressPercent < 0.75 + ? R.drawable.ic_volume_down_white_24dp + : R.drawable.ic_volume_up_white_24dp; + + playerImpl.getVolumeImageView().setImageDrawable( + AppCompatResources.getDrawable(getApplicationContext(), resId) + ); + + if (playerImpl.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) { + animateView(playerImpl.getVolumeRelativeLayout(), SCALE_AND_ALPHA, true, 200); + } + if (playerImpl.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) { + playerImpl.getBrightnessRelativeLayout().setVisibility(View.GONE); + } + } else if (isBrightnessGestureEnabled && acceptBrightnessArea) { + playerImpl.getBrightnessProgressBar().incrementProgressBy((int) distanceY); + float currentProgressPercent + = (float) playerImpl.getBrightnessProgressBar().getProgress() + / playerImpl.getMaxGestureLength(); + WindowManager.LayoutParams layoutParams = getWindow().getAttributes(); + layoutParams.screenBrightness = currentProgressPercent; + getWindow().setAttributes(layoutParams); + + if (DEBUG) { + Log.d(TAG, "onScroll().brightnessControl, currentBrightness = " + + currentProgressPercent); + } + + final int resId = currentProgressPercent < 0.25 + ? R.drawable.ic_brightness_low_white_24dp + : currentProgressPercent < 0.75 + ? R.drawable.ic_brightness_medium_white_24dp + : R.drawable.ic_brightness_high_white_24dp; + + playerImpl.getBrightnessImageView().setImageDrawable( + AppCompatResources.getDrawable(getApplicationContext(), resId) + ); + + if (playerImpl.getBrightnessRelativeLayout().getVisibility() != View.VISIBLE) { + animateView(playerImpl.getBrightnessRelativeLayout(), SCALE_AND_ALPHA, true, + 200); + } + if (playerImpl.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) { + playerImpl.getVolumeRelativeLayout().setVisibility(View.GONE); + } + } + return true; + } + + private int getNavigationBarHeight() { + int resId = getResources().getIdentifier("navigation_bar_height", "dimen", "android"); + if (resId > 0) { + return getResources().getDimensionPixelSize(resId); + } + return 0; + } + + private int getStatusBarHeight() { + int resId = getResources().getIdentifier("status_bar_height", "dimen", "android"); + if (resId > 0) { + return getResources().getDimensionPixelSize(resId); + } + return 0; + } + + private void onScrollEnd() { + if (DEBUG) { + Log.d(TAG, "onScrollEnd() called"); + } + + if (playerImpl.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getVolumeRelativeLayout(), SCALE_AND_ALPHA, false, + 200, 200); + } + if (playerImpl.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) { + animateView(playerImpl.getBrightnessRelativeLayout(), SCALE_AND_ALPHA, false, + 200, 200); + } + + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + } + + @Override + public boolean onTouch(final View v, final MotionEvent event) { +// if (DEBUG) { +// Log.d(TAG, "onTouch() called with: v = [" + v + "], event = [" + event + "]"); +// } + gestureDetector.onTouchEvent(event); + if (event.getAction() == MotionEvent.ACTION_UP && isMoving) { + isMoving = false; + onScrollEnd(); + } + return true; + } + + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/PlayerServiceBinder.java b/app/src/main/java/org/schabi/newpipelegacy/player/PlayerServiceBinder.java new file mode 100644 index 000000000..067fdc046 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/PlayerServiceBinder.java @@ -0,0 +1,17 @@ +package org.schabi.newpipelegacy.player; + +import android.os.Binder; + +import androidx.annotation.NonNull; + +class PlayerServiceBinder extends Binder { + private final BasePlayer basePlayer; + + PlayerServiceBinder(@NonNull final BasePlayer basePlayer) { + this.basePlayer = basePlayer; + } + + BasePlayer getPlayerInstance() { + return basePlayer; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/PlayerState.java b/app/src/main/java/org/schabi/newpipelegacy/player/PlayerState.java new file mode 100644 index 000000000..cfad92107 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/PlayerState.java @@ -0,0 +1,79 @@ +package org.schabi.newpipelegacy.player; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; + +import java.io.Serializable; + +public class PlayerState implements Serializable { + + @NonNull + private final PlayQueue playQueue; + private final int repeatMode; + private final float playbackSpeed; + private final float playbackPitch; + @Nullable + private final String playbackQuality; + private final boolean playbackSkipSilence; + private final boolean wasPlaying; + + PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, + final float playbackSpeed, final float playbackPitch, + final boolean playbackSkipSilence, final boolean wasPlaying) { + this(playQueue, repeatMode, playbackSpeed, playbackPitch, null, + playbackSkipSilence, wasPlaying); + } + + PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, + final float playbackSpeed, final float playbackPitch, + @Nullable final String playbackQuality, final boolean playbackSkipSilence, + final boolean wasPlaying) { + this.playQueue = playQueue; + this.repeatMode = repeatMode; + this.playbackSpeed = playbackSpeed; + this.playbackPitch = playbackPitch; + this.playbackQuality = playbackQuality; + this.playbackSkipSilence = playbackSkipSilence; + this.wasPlaying = wasPlaying; + } + + /*////////////////////////////////////////////////////////////////////////// + // Serdes + //////////////////////////////////////////////////////////////////////////*/ + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + public PlayQueue getPlayQueue() { + return playQueue; + } + + public int getRepeatMode() { + return repeatMode; + } + + public float getPlaybackSpeed() { + return playbackSpeed; + } + + public float getPlaybackPitch() { + return playbackPitch; + } + + @Nullable + public String getPlaybackQuality() { + return playbackQuality; + } + + public boolean isPlaybackSkipSilence() { + return playbackSkipSilence; + } + + public boolean wasPlaying() { + return wasPlaying; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayer.java b/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayer.java new file mode 100644 index 000000000..abd5e2bb4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayer.java @@ -0,0 +1,1311 @@ +/* + * Copyright 2017 Mauricio Colli + * PopupVideoPlayer.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.player; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.annotation.SuppressLint; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.os.Build; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.view.animation.AnticipateInterpolator; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.RemoteViews; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.text.CaptionStyleCompat; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.nostra13.universalimageloader.core.assist.FailReason; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.player.event.PlayerEventListener; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.player.resolver.MediaSourceTag; +import org.schabi.newpipelegacy.player.resolver.VideoPlaybackResolver; +import org.schabi.newpipelegacy.util.ListHelper; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.List; + +import static org.schabi.newpipelegacy.player.BasePlayer.STATE_PLAYING; +import static org.schabi.newpipelegacy.player.VideoPlayer.DEFAULT_CONTROLS_DURATION; +import static org.schabi.newpipelegacy.player.VideoPlayer.DEFAULT_CONTROLS_HIDE_TIME; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +/** + * Service Popup Player implementing {@link VideoPlayer}. + * + * @author mauriciocolli + */ +public final class PopupVideoPlayer extends Service { + public static final String ACTION_CLOSE = "org.schabi.newpipe.player.PopupVideoPlayer.CLOSE"; + public static final String ACTION_PLAY_PAUSE + = "org.schabi.newpipe.player.PopupVideoPlayer.PLAY_PAUSE"; + public static final String ACTION_REPEAT = "org.schabi.newpipe.player.PopupVideoPlayer.REPEAT"; + private static final String TAG = ".PopupVideoPlayer"; + private static final boolean DEBUG = BasePlayer.DEBUG; + private static final int NOTIFICATION_ID = 40028922; + private static final String POPUP_SAVED_WIDTH = "popup_saved_width"; + private static final String POPUP_SAVED_X = "popup_saved_x"; + private static final String POPUP_SAVED_Y = "popup_saved_y"; + + private static final int MINIMUM_SHOW_EXTRA_WIDTH_DP = 300; + + private static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; + private static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; + + private WindowManager windowManager; + private WindowManager.LayoutParams popupLayoutParams; + private GestureDetector popupGestureDetector; + + private View closeOverlayView; + private FloatingActionButton closeOverlayButton; + + private int tossFlingVelocity; + + private float screenWidth; + private float screenHeight; + private float popupWidth; + private float popupHeight; + + private float minimumWidth; + private float minimumHeight; + private float maximumWidth; + private float maximumHeight; + + private NotificationManager notificationManager; + private NotificationCompat.Builder notBuilder; + private RemoteViews notRemoteView; + + private VideoPlayerImpl playerImpl; + private boolean isPopupClosing = false; + + /*////////////////////////////////////////////////////////////////////////// + // Service-Activity Binder + //////////////////////////////////////////////////////////////////////////*/ + + private PlayerEventListener activityListener; + private IBinder mBinder; + + /*////////////////////////////////////////////////////////////////////////// + // Service LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate() { + assureCorrectAppLanguage(this); + windowManager = (WindowManager) getSystemService(WINDOW_SERVICE); + notificationManager = ((NotificationManager) getSystemService(NOTIFICATION_SERVICE)); + + playerImpl = new VideoPlayerImpl(this); + ThemeHelper.setTheme(this); + + mBinder = new PlayerServiceBinder(playerImpl); + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "], " + + "flags = [" + flags + "], startId = [" + startId + "]"); + } + if (playerImpl.getPlayer() == null) { + initPopup(); + initPopupCloseOverlay(); + } + + playerImpl.handleIntent(intent); + + return START_NOT_STICKY; + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + assureCorrectAppLanguage(this); + if (DEBUG) { + Log.d(TAG, "onConfigurationChanged() called with: " + + "newConfig = [" + newConfig + "]"); + } + updateScreenSize(); + updatePopupSize(popupLayoutParams.width, -1); + checkPopupPositionBounds(); + } + + @Override + public void onDestroy() { + if (DEBUG) { + Log.d(TAG, "onDestroy() called"); + } + closePopup(); + } + + @Override + protected void attachBaseContext(final Context base) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); + } + + @Override + public IBinder onBind(final Intent intent) { + return mBinder; + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @SuppressLint("RtlHardcoded") + private void initPopup() { + if (DEBUG) { + Log.d(TAG, "initPopup() called"); + } + View rootView = View.inflate(this, R.layout.player_popup, null); + playerImpl.setup(rootView); + + tossFlingVelocity = PlayerHelper.getTossFlingVelocity(this); + + updateScreenSize(); + + final boolean popupRememberSizeAndPos = PlayerHelper.isRememberingPopupDimensions(this); + final float defaultSize = getResources().getDimension(R.dimen.popup_default_width); + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); + popupWidth = popupRememberSizeAndPos + ? sharedPreferences.getFloat(POPUP_SAVED_WIDTH, defaultSize) : defaultSize; + + final int layoutParamType = Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_PHONE + : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + + popupLayoutParams = new WindowManager.LayoutParams( + (int) popupWidth, (int) getMinimumVideoHeight(popupWidth), + layoutParamType, + IDLE_WINDOW_FLAGS, + PixelFormat.TRANSLUCENT); + popupLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; + popupLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + + int centerX = (int) (screenWidth / 2f - popupWidth / 2f); + int centerY = (int) (screenHeight / 2f - popupHeight / 2f); + popupLayoutParams.x = popupRememberSizeAndPos + ? sharedPreferences.getInt(POPUP_SAVED_X, centerX) : centerX; + popupLayoutParams.y = popupRememberSizeAndPos + ? sharedPreferences.getInt(POPUP_SAVED_Y, centerY) : centerY; + + checkPopupPositionBounds(); + + PopupWindowGestureListener listener = new PopupWindowGestureListener(); + popupGestureDetector = new GestureDetector(this, listener); + rootView.setOnTouchListener(listener); + + playerImpl.getLoadingPanel().setMinimumWidth(popupLayoutParams.width); + playerImpl.getLoadingPanel().setMinimumHeight(popupLayoutParams.height); + windowManager.addView(rootView, popupLayoutParams); + } + + @SuppressLint("RtlHardcoded") + private void initPopupCloseOverlay() { + if (DEBUG) { + Log.d(TAG, "initPopupCloseOverlay() called"); + } + closeOverlayView = View.inflate(this, R.layout.player_popup_close_overlay, null); + closeOverlayButton = closeOverlayView.findViewById(R.id.closeButton); + + final int layoutParamType = Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_PHONE + : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; + + WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, + layoutParamType, + flags, + PixelFormat.TRANSLUCENT); + closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; + closeOverlayLayoutParams.softInputMode = WindowManager + .LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + + closeOverlayButton.setVisibility(View.GONE); + windowManager.addView(closeOverlayView, closeOverlayLayoutParams); + } + + /*////////////////////////////////////////////////////////////////////////// + // Notification + //////////////////////////////////////////////////////////////////////////*/ + + private void resetNotification() { + notBuilder = createNotification(); + } + + private NotificationCompat.Builder createNotification() { + notRemoteView = new RemoteViews(BuildConfig.APPLICATION_ID, + R.layout.player_popup_notification); + + notRemoteView.setTextViewText(R.id.notificationSongName, playerImpl.getVideoTitle()); + notRemoteView.setTextViewText(R.id.notificationArtist, playerImpl.getUploaderName()); + notRemoteView.setImageViewBitmap(R.id.notificationCover, playerImpl.getThumbnail()); + + notRemoteView.setOnClickPendingIntent(R.id.notificationPlayPause, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_PLAY_PAUSE), + PendingIntent.FLAG_UPDATE_CURRENT)); + notRemoteView.setOnClickPendingIntent(R.id.notificationStop, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_CLOSE), + PendingIntent.FLAG_UPDATE_CURRENT)); + notRemoteView.setOnClickPendingIntent(R.id.notificationRepeat, + PendingIntent.getBroadcast(this, NOTIFICATION_ID, new Intent(ACTION_REPEAT), + PendingIntent.FLAG_UPDATE_CURRENT)); + + // Starts popup player activity -- attempts to unlock lockscreen + final Intent intent = NavigationHelper.getPopupPlayerActivityIntent(this); + notRemoteView.setOnClickPendingIntent(R.id.notificationContent, + PendingIntent.getActivity(this, NOTIFICATION_ID, intent, + PendingIntent.FLAG_UPDATE_CURRENT)); + + setRepeatModeRemote(notRemoteView, playerImpl.getRepeatMode()); + + NotificationCompat.Builder builder = new NotificationCompat + .Builder(this, getString(R.string.notification_channel_id)) + .setOngoing(true) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContent(notRemoteView); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + builder.setPriority(NotificationCompat.PRIORITY_MAX); + } + return builder; + } + + /** + * Updates the notification, and the play/pause button in it. + * Used for changes on the remoteView + * + * @param drawableId if != -1, sets the drawable with that id on the play/pause button + */ + private void updateNotification(final int drawableId) { + if (DEBUG) { + Log.d(TAG, "updateNotification() called with: drawableId = [" + drawableId + "]"); + } + if (notBuilder == null || notRemoteView == null) { + return; + } + if (drawableId != -1) { + notRemoteView.setImageViewResource(R.id.notificationPlayPause, drawableId); + } + notificationManager.notify(NOTIFICATION_ID, notBuilder.build()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Misc + //////////////////////////////////////////////////////////////////////////*/ + + public void closePopup() { + if (DEBUG) { + Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); + } + if (isPopupClosing) { + return; + } + isPopupClosing = true; + + if (playerImpl != null) { + playerImpl.savePlaybackState(); + if (playerImpl.getRootView() != null) { + windowManager.removeView(playerImpl.getRootView()); + } + playerImpl.setRootView(null); + playerImpl.stopActivityBinding(); + playerImpl.destroy(); + playerImpl = null; + } + + mBinder = null; + if (notificationManager != null) { + notificationManager.cancel(NOTIFICATION_ID); + } + + animateOverlayAndFinishService(); + } + + private void animateOverlayAndFinishService() { + final int targetTranslationY = (int) (closeOverlayButton.getRootView().getHeight() + - closeOverlayButton.getY()); + + closeOverlayButton.animate().setListener(null).cancel(); + closeOverlayButton.animate() + .setInterpolator(new AnticipateInterpolator()) + .translationY(targetTranslationY) + .setDuration(400) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(final Animator animation) { + end(); + } + + @Override + public void onAnimationEnd(final Animator animation) { + end(); + } + + private void end() { + windowManager.removeView(closeOverlayView); + + stopForeground(true); + stopSelf(); + } + }).start(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + /** + * @see #checkPopupPositionBounds(float, float) + * @return if the popup was out of bounds and have been moved back to it + */ + @SuppressWarnings("UnusedReturnValue") + private boolean checkPopupPositionBounds() { + return checkPopupPositionBounds(screenWidth, screenHeight); + } + + /** + * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary + * that goes from (0, 0) to (boundaryWidth, boundaryHeight). + *

+ * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed + * and {@code true} is returned to represent this change. + *

+ * + * @param boundaryWidth width of the boundary + * @param boundaryHeight height of the boundary + * @return if the popup was out of bounds and have been moved back to it + */ + private boolean checkPopupPositionBounds(final float boundaryWidth, + final float boundaryHeight) { + if (DEBUG) { + Log.d(TAG, "checkPopupPositionBounds() called with: " + + "boundaryWidth = [" + boundaryWidth + "], " + + "boundaryHeight = [" + boundaryHeight + "]"); + } + + if (popupLayoutParams.x < 0) { + popupLayoutParams.x = 0; + return true; + } else if (popupLayoutParams.x > boundaryWidth - popupLayoutParams.width) { + popupLayoutParams.x = (int) (boundaryWidth - popupLayoutParams.width); + return true; + } + + if (popupLayoutParams.y < 0) { + popupLayoutParams.y = 0; + return true; + } else if (popupLayoutParams.y > boundaryHeight - popupLayoutParams.height) { + popupLayoutParams.y = (int) (boundaryHeight - popupLayoutParams.height); + return true; + } + + return false; + } + + private void savePositionAndSize() { + SharedPreferences sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(PopupVideoPlayer.this); + sharedPreferences.edit().putInt(POPUP_SAVED_X, popupLayoutParams.x).apply(); + sharedPreferences.edit().putInt(POPUP_SAVED_Y, popupLayoutParams.y).apply(); + sharedPreferences.edit().putFloat(POPUP_SAVED_WIDTH, popupLayoutParams.width).apply(); + } + + private float getMinimumVideoHeight(final float width) { + final float height = width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have +// if (DEBUG) { +// Log.d(TAG, "getMinimumVideoHeight() called with: width = [" + width + "], " +// + "returned: " + height); +// } + return height; + } + + private void updateScreenSize() { + DisplayMetrics metrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(metrics); + + screenWidth = metrics.widthPixels; + screenHeight = metrics.heightPixels; + if (DEBUG) { + Log.d(TAG, "updateScreenSize() called > screenWidth = " + screenWidth + ", " + + "screenHeight = " + screenHeight); + } + + popupWidth = getResources().getDimension(R.dimen.popup_default_width); + popupHeight = getMinimumVideoHeight(popupWidth); + + minimumWidth = getResources().getDimension(R.dimen.popup_minimum_width); + minimumHeight = getMinimumVideoHeight(minimumWidth); + + maximumWidth = screenWidth; + maximumHeight = screenHeight; + } + + private void updatePopupSize(final int width, final int height) { + if (playerImpl == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "updatePopupSize() called with: " + + "width = [" + width + "], height = [" + height + "]"); + } + + final int actualWidth = (int) (width > maximumWidth ? maximumWidth + : width < minimumWidth ? minimumWidth : width); + + final int actualHeight; + if (height == -1) { + actualHeight = (int) getMinimumVideoHeight(width); + } else { + actualHeight = (int) (height > maximumHeight ? maximumHeight + : height < minimumHeight ? minimumHeight : height); + } + + popupLayoutParams.width = actualWidth; + popupLayoutParams.height = actualHeight; + popupWidth = actualWidth; + popupHeight = actualHeight; + + if (DEBUG) { + Log.d(TAG, "updatePopupSize() updated values: " + + "width = [" + actualWidth + "], height = [" + actualHeight + "]"); + } + windowManager.updateViewLayout(playerImpl.getRootView(), popupLayoutParams); + } + + protected void setRepeatModeRemote(final RemoteViews remoteViews, final int repeatMode) { + final String methodName = "setImageResource"; + + if (remoteViews == null) { + return; + } + + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + remoteViews.setInt(R.id.notificationRepeat, methodName, + R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + remoteViews.setInt(R.id.notificationRepeat, methodName, + R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + remoteViews.setInt(R.id.notificationRepeat, methodName, + R.drawable.exo_controls_repeat_all); + break; + } + } + + private void updateWindowFlags(final int flags) { + if (popupLayoutParams == null || windowManager == null || playerImpl == null) { + return; + } + + popupLayoutParams.flags = flags; + windowManager.updateViewLayout(playerImpl.getRootView(), popupLayoutParams); + } + /////////////////////////////////////////////////////////////////////////// + + protected class VideoPlayerImpl extends VideoPlayer implements View.OnLayoutChangeListener { + private TextView resizingIndicator; + private ImageButton fullScreenButton; + private ImageView videoPlayPause; + + private View extraOptionsView; + private View closingOverlayView; + + VideoPlayerImpl(final Context context) { + super("VideoPlayerImpl" + PopupVideoPlayer.TAG, context); + } + + @Override + public void handleIntent(final Intent intent) { + super.handleIntent(intent); + + resetNotification(); + startForeground(NOTIFICATION_ID, notBuilder.build()); + } + + @Override + public void initViews(final View view) { + super.initViews(view); + resizingIndicator = view.findViewById(R.id.resizing_indicator); + fullScreenButton = view.findViewById(R.id.fullScreenButton); + fullScreenButton.setOnClickListener(v -> onFullScreenButtonClicked()); + videoPlayPause = view.findViewById(R.id.videoPlayPause); + + extraOptionsView = view.findViewById(R.id.extraOptionsView); + closingOverlayView = view.findViewById(R.id.closingOverlay); + view.addOnLayoutChangeListener(this); + } + + @Override + public void initListeners() { + super.initListeners(); + videoPlayPause.setOnClickListener(v -> onPlayPause()); + } + + @Override + protected void setupSubtitleView(@NonNull final SubtitleView view, final float captionScale, + @NonNull final CaptionStyleCompat captionStyle) { + float captionRatio = (captionScale - 1f) / 5f + 1f; + view.setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio); + view.setApplyEmbeddedStyles(captionStyle.equals(CaptionStyleCompat.DEFAULT)); + view.setStyle(captionStyle); + } + + @Override + public void onLayoutChange(final View view, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, + final int oldRight, final int oldBottom) { + float widthDp = Math.abs(right - left) / getResources().getDisplayMetrics().density; + final int visibility = widthDp > MINIMUM_SHOW_EXTRA_WIDTH_DP ? View.VISIBLE : View.GONE; + extraOptionsView.setVisibility(visibility); + } + + @Override + public void destroy() { + if (notRemoteView != null) { + notRemoteView.setImageViewBitmap(R.id.notificationCover, null); + } + super.destroy(); + } + + @Override + public void onFullScreenButtonClicked() { + super.onFullScreenButtonClicked(); + + if (DEBUG) { + Log.d(TAG, "onFullScreenButtonClicked() called"); + } + + setRecovery(); + final Intent intent = NavigationHelper.getPlayerIntent( + context, + MainVideoPlayer.class, + this.getPlayQueue(), + this.getRepeatMode(), + this.getPlaybackSpeed(), + this.getPlaybackPitch(), + this.getPlaybackSkipSilence(), + this.getPlaybackQuality(), + false, + !isPlaying(), + isMuted() + ); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + closePopup(); + } + + @Override + public void onDismiss(final PopupMenu menu) { + super.onDismiss(menu); + if (isPlaying()) { + hideControls(500, 0); + } + } + + @Override + protected int nextResizeMode(final int resizeMode) { + if (resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FILL) { + return AspectRatioFrameLayout.RESIZE_MODE_FIT; + } else { + return AspectRatioFrameLayout.RESIZE_MODE_FILL; + } + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + super.onStopTrackingTouch(seekBar); + if (wasPlaying()) { + hideControls(100, 0); + } + } + + @Override + public void onShuffleClicked() { + super.onShuffleClicked(); + updatePlayback(); + } + + @Override + public void onMuteUnmuteButtonClicked() { + super.onMuteUnmuteButtonClicked(); + updatePlayback(); + } + + @Override + public void onUpdateProgress(final int currentProgress, final int duration, + final int bufferPercent) { + updateProgress(currentProgress, duration, bufferPercent); + super.onUpdateProgress(currentProgress, duration, bufferPercent); + } + + @Override + protected VideoPlaybackResolver.QualityResolver getQualityResolver() { + return new VideoPlaybackResolver.QualityResolver() { + @Override + public int getDefaultResolutionIndex(final List sortedVideos) { + return ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos); + } + + @Override + public int getOverrideResolutionIndex(final List sortedVideos, + final String playbackQuality) { + return ListHelper.getPopupResolutionIndex(context, sortedVideos, + playbackQuality); + } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail Loading + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onLoadingComplete(final String imageUri, final View view, + final Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (playerImpl == null) { + return; + } + // rebuild notification here since remote view does not release bitmaps, + // causing memory leaks + resetNotification(); + updateNotification(-1); + } + + @Override + public void onLoadingFailed(final String imageUri, final View view, + final FailReason failReason) { + super.onLoadingFailed(imageUri, view, failReason); + resetNotification(); + updateNotification(-1); + } + + @Override + public void onLoadingCancelled(final String imageUri, final View view) { + super.onLoadingCancelled(imageUri, view); + resetNotification(); + updateNotification(-1); + } + + /*////////////////////////////////////////////////////////////////////////// + // Activity Event Listener + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void setActivityListener(final PlayerEventListener listener) { + activityListener = listener; + updateMetadata(); + updatePlayback(); + triggerProgressUpdate(); + } + + /*package-private*/ void removeActivityListener(final PlayerEventListener listener) { + if (activityListener == listener) { + activityListener = null; + } + } + + private void updateMetadata() { + if (activityListener != null && getCurrentMetadata() != null) { + activityListener.onMetadataUpdate(getCurrentMetadata().getMetadata()); + } + } + + private void updatePlayback() { + if (activityListener != null && simpleExoPlayer != null && playQueue != null) { + activityListener.onPlaybackUpdate(currentState, getRepeatMode(), + playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); + } + } + + private void updateProgress(final int currentProgress, final int duration, + final int bufferPercent) { + if (activityListener != null) { + activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); + } + } + + private void stopActivityBinding() { + if (activityListener != null) { + activityListener.onServiceStopped(); + activityListener = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Video Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onRepeatModeChanged(final int i) { + super.onRepeatModeChanged(i); + setRepeatModeRemote(notRemoteView, i); + updatePlayback(); + resetNotification(); + updateNotification(-1); + } + + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + updatePlayback(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected void onMetadataChanged(@NonNull final MediaSourceTag tag) { + super.onMetadataChanged(tag); + resetNotification(); + updateNotification(-1); + updateMetadata(); + } + + @Override + public void onPlaybackShutdown() { + super.onPlaybackShutdown(); + closePopup(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast Receiver + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected void setupBroadcastReceiver(final IntentFilter intentFltr) { + super.setupBroadcastReceiver(intentFltr); + if (DEBUG) { + Log.d(TAG, "setupBroadcastReceiver() called with: " + + "intentFilter = [" + intentFltr + "]"); + } + intentFltr.addAction(ACTION_CLOSE); + intentFltr.addAction(ACTION_PLAY_PAUSE); + intentFltr.addAction(ACTION_REPEAT); + + intentFltr.addAction(Intent.ACTION_SCREEN_ON); + intentFltr.addAction(Intent.ACTION_SCREEN_OFF); + } + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (intent == null || intent.getAction() == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); + } + switch (intent.getAction()) { + case ACTION_CLOSE: + closePopup(); + break; + case ACTION_PLAY_PAUSE: + onPlayPause(); + break; + case ACTION_REPEAT: + onRepeatClicked(); + break; + case Intent.ACTION_SCREEN_ON: + enableVideoRenderer(true); + break; + case Intent.ACTION_SCREEN_OFF: + enableVideoRenderer(false); + break; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // States + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void changeState(final int state) { + super.changeState(state); + updatePlayback(); + } + + @Override + public void onBlocked() { + super.onBlocked(); + resetNotification(); + updateNotification(R.drawable.exo_controls_play); + } + + @Override + public void onPlaying() { + super.onPlaying(); + + updateWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); + + resetNotification(); + updateNotification(R.drawable.exo_controls_pause); + + videoPlayPause.setBackgroundResource(R.drawable.exo_controls_pause); + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + + startForeground(NOTIFICATION_ID, notBuilder.build()); + } + + @Override + public void onBuffering() { + super.onBuffering(); + resetNotification(); + updateNotification(R.drawable.exo_controls_play); + } + + @Override + public void onPaused() { + super.onPaused(); + + updateWindowFlags(IDLE_WINDOW_FLAGS); + + resetNotification(); + updateNotification(R.drawable.exo_controls_play); + videoPlayPause.setBackgroundResource(R.drawable.exo_controls_play); + + stopForeground(false); + } + + @Override + public void onPausedSeek() { + super.onPausedSeek(); + resetNotification(); + updateNotification(R.drawable.exo_controls_play); + + videoPlayPause.setBackgroundResource(R.drawable.exo_controls_play); + } + + @Override + public void onCompleted() { + super.onCompleted(); + + updateWindowFlags(IDLE_WINDOW_FLAGS); + + resetNotification(); + updateNotification(R.drawable.ic_replay_white_24dp); + videoPlayPause.setBackgroundResource(R.drawable.ic_replay_white_24dp); + + stopForeground(false); + } + + @Override + public void showControlsThenHide() { + videoPlayPause.setVisibility(View.VISIBLE); + super.showControlsThenHide(); + } + + public void showControls(final long duration) { + videoPlayPause.setVisibility(View.VISIBLE); + super.showControls(duration); + } + + public void hideControls(final long duration, final long delay) { + super.hideControlsAndButton(duration, delay, videoPlayPause); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + /*package-private*/ void enableVideoRenderer(final boolean enable) { + final int videoRendererIndex = getRendererIndex(C.TRACK_TYPE_VIDEO); + if (videoRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(videoRendererIndex, !enable)); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + + @SuppressWarnings("WeakerAccess") + public TextView getResizingIndicator() { + return resizingIndicator; + } + + public View getClosingOverlayView() { + return closingOverlayView; + } + } + + private class PopupWindowGestureListener extends GestureDetector.SimpleOnGestureListener + implements View.OnTouchListener { + private int initialPopupX; + private int initialPopupY; + private boolean isMoving; + private boolean isResizing; + + //initial co-ordinates and distance between fingers + private double initPointerDistance = -1; + private float initFirstPointerX = -1; + private float initFirstPointerY = -1; + private float initSecPointerX = -1; + private float initSecPointerY = -1; + + + @Override + public boolean onDoubleTap(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onDoubleTap() called with: e = [" + e + "], " + + "rawXy = " + e.getRawX() + ", " + e.getRawY() + + ", xy = " + e.getX() + ", " + e.getY()); + } + if (playerImpl == null || !playerImpl.isPlaying()) { + return false; + } + + playerImpl.hideControls(0, 0); + + if (e.getX() > popupWidth / 2) { + playerImpl.onFastForward(); + } else { + playerImpl.onFastRewind(); + } + + return true; + } + + @Override + public boolean onSingleTapConfirmed(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onSingleTapConfirmed() called with: e = [" + e + "]"); + } + if (playerImpl == null || playerImpl.getPlayer() == null) { + return false; + } + if (playerImpl.isControlsVisible()) { + playerImpl.hideControls(100, 100); + } else { + playerImpl.showControlsThenHide(); + + } + return true; + } + + @Override + public boolean onDown(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onDown() called with: e = [" + e + "]"); + } + + // Fix popup position when the user touch it, it may have the wrong one + // because the soft input is visible (the draggable area is currently resized). + checkPopupPositionBounds(closeOverlayView.getWidth(), closeOverlayView.getHeight()); + + initialPopupX = popupLayoutParams.x; + initialPopupY = popupLayoutParams.y; + popupWidth = popupLayoutParams.width; + popupHeight = popupLayoutParams.height; + return super.onDown(e); + } + + @Override + public void onLongPress(final MotionEvent e) { + if (DEBUG) { + Log.d(TAG, "onLongPress() called with: e = [" + e + "]"); + } + updateScreenSize(); + checkPopupPositionBounds(); + updatePopupSize((int) screenWidth, -1); + } + + @Override + public boolean onScroll(final MotionEvent initialEvent, final MotionEvent movingEvent, + final float distanceX, final float distanceY) { + if (isResizing || playerImpl == null) { + return super.onScroll(initialEvent, movingEvent, distanceX, distanceY); + } + + if (!isMoving) { + animateView(closeOverlayButton, true, 200); + } + + isMoving = true; + + float diffX = (int) (movingEvent.getRawX() - initialEvent.getRawX()); + float posX = (int) (initialPopupX + diffX); + float diffY = (int) (movingEvent.getRawY() - initialEvent.getRawY()); + float posY = (int) (initialPopupY + diffY); + + if (posX > (screenWidth - popupWidth)) { + posX = (int) (screenWidth - popupWidth); + } else if (posX < 0) { + posX = 0; + } + + if (posY > (screenHeight - popupHeight)) { + posY = (int) (screenHeight - popupHeight); + } else if (posY < 0) { + posY = 0; + } + + popupLayoutParams.x = (int) posX; + popupLayoutParams.y = (int) posY; + + final View closingOverlayView = playerImpl.getClosingOverlayView(); + if (isInsideClosingRadius(movingEvent)) { + if (closingOverlayView.getVisibility() == View.GONE) { + animateView(closingOverlayView, true, 250); + } + } else { + if (closingOverlayView.getVisibility() == View.VISIBLE) { + animateView(closingOverlayView, false, 0); + } + } + +// if (DEBUG) { +// Log.d(TAG, "PopupVideoPlayer.onScroll = " +// + "e1.getRaw = [" + initialEvent.getRawX() + ", " +// + initialEvent.getRawY() + "], " +// + "e1.getX,Y = [" + initialEvent.getX() + ", " +// + initialEvent.getY() + "], " +// + "e2.getRaw = [" + movingEvent.getRawX() + ", " +// + movingEvent.getRawY() + "], " +// + "e2.getX,Y = [" + movingEvent.getX() + ", " + movingEvent.getY() + "], " +// + "distanceX,Y = [" + distanceX + ", " + distanceY + "], " +// + "posX,Y = [" + posX + ", " + posY + "], " +// + "popupW,H = [" + popupWidth + " x " + popupHeight + "]"); +// } + windowManager.updateViewLayout(playerImpl.getRootView(), popupLayoutParams); + return true; + } + + private void onScrollEnd(final MotionEvent event) { + if (DEBUG) { + Log.d(TAG, "onScrollEnd() called"); + } + if (playerImpl == null) { + return; + } + if (playerImpl.isControlsVisible() && playerImpl.getCurrentState() == STATE_PLAYING) { + playerImpl.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + + if (isInsideClosingRadius(event)) { + closePopup(); + } else { + animateView(playerImpl.getClosingOverlayView(), false, 0); + + if (!isPopupClosing) { + animateView(closeOverlayButton, false, 200); + } + } + } + + @Override + public boolean onFling(final MotionEvent e1, final MotionEvent e2, + final float velocityX, final float velocityY) { + if (DEBUG) { + Log.d(TAG, "Fling velocity: dX=[" + velocityX + "], dY=[" + velocityY + "]"); + } + if (playerImpl == null) { + return false; + } + + final float absVelocityX = Math.abs(velocityX); + final float absVelocityY = Math.abs(velocityY); + if (Math.max(absVelocityX, absVelocityY) > tossFlingVelocity) { + if (absVelocityX > tossFlingVelocity) { + popupLayoutParams.x = (int) velocityX; + } + if (absVelocityY > tossFlingVelocity) { + popupLayoutParams.y = (int) velocityY; + } + checkPopupPositionBounds(); + windowManager.updateViewLayout(playerImpl.getRootView(), popupLayoutParams); + return true; + } + return false; + } + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + popupGestureDetector.onTouchEvent(event); + if (playerImpl == null) { + return false; + } + if (event.getPointerCount() == 2 && !isMoving && !isResizing) { + if (DEBUG) { + Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing."); + } + playerImpl.showAndAnimateControl(-1, true); + playerImpl.getLoadingPanel().setVisibility(View.GONE); + + playerImpl.hideControls(0, 0); + animateView(playerImpl.getCurrentDisplaySeek(), false, 0, 0); + animateView(playerImpl.getResizingIndicator(), true, 200, 0); + + //record co-ordinates of fingers + initFirstPointerX = event.getX(0); + initFirstPointerY = event.getY(0); + initSecPointerX = event.getX(1); + initSecPointerY = event.getY(1); + //record distance between fingers + initPointerDistance = Math.hypot(initFirstPointerX - initSecPointerX, + initFirstPointerY - initSecPointerY); + + isResizing = true; + } + + if (event.getAction() == MotionEvent.ACTION_MOVE && !isMoving && isResizing) { + if (DEBUG) { + Log.d(TAG, "onTouch() ACTION_MOVE > v = [" + v + "], " + + "e1.getRaw = [" + event.getRawX() + ", " + event.getRawY() + "]"); + } + return handleMultiDrag(event); + } + + if (event.getAction() == MotionEvent.ACTION_UP) { + if (DEBUG) { + Log.d(TAG, "onTouch() ACTION_UP > v = [" + v + "], " + + "e1.getRaw = [" + event.getRawX() + ", " + event.getRawY() + "]"); + } + if (isMoving) { + isMoving = false; + onScrollEnd(event); + } + + if (isResizing) { + isResizing = false; + + initPointerDistance = -1; + initFirstPointerX = -1; + initFirstPointerY = -1; + initSecPointerX = -1; + initSecPointerY = -1; + + animateView(playerImpl.getResizingIndicator(), false, 100, 0); + playerImpl.changeState(playerImpl.getCurrentState()); + } + + if (!isPopupClosing) { + savePositionAndSize(); + } + } + + v.performClick(); + return true; + } + + private boolean handleMultiDrag(final MotionEvent event) { + if (initPointerDistance != -1 && event.getPointerCount() == 2) { + // get the movements of the fingers + double firstPointerMove = Math.hypot(event.getX(0) - initFirstPointerX, + event.getY(0) - initFirstPointerY); + double secPointerMove = Math.hypot(event.getX(1) - initSecPointerX, + event.getY(1) - initSecPointerY); + + // minimum threshold beyond which pinch gesture will work + int minimumMove = ViewConfiguration.get(PopupVideoPlayer.this).getScaledTouchSlop(); + + if (Math.max(firstPointerMove, secPointerMove) > minimumMove) { + // calculate current distance between the pointers + double currentPointerDistance = + Math.hypot(event.getX(0) - event.getX(1), + event.getY(0) - event.getY(1)); + + // change co-ordinates of popup so the center stays at the same position + double newWidth = (popupWidth * currentPointerDistance / initPointerDistance); + initPointerDistance = currentPointerDistance; + popupLayoutParams.x += (popupWidth - newWidth) / 2; + + checkPopupPositionBounds(); + updateScreenSize(); + + updatePopupSize((int) Math.min(screenWidth, newWidth), -1); + return true; + } + } + return false; + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private int distanceFromCloseButton(final MotionEvent popupMotionEvent) { + final int closeOverlayButtonX = closeOverlayButton.getLeft() + + closeOverlayButton.getWidth() / 2; + final int closeOverlayButtonY = closeOverlayButton.getTop() + + closeOverlayButton.getHeight() / 2; + + float fingerX = popupLayoutParams.x + popupMotionEvent.getX(); + float fingerY = popupLayoutParams.y + popupMotionEvent.getY(); + + return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2) + + Math.pow(closeOverlayButtonY - fingerY, 2)); + } + + private float getClosingRadius() { + final int buttonRadius = closeOverlayButton.getWidth() / 2; + // 20% wider than the button itself + return buttonRadius * 1.2f; + } + + private boolean isInsideClosingRadius(final MotionEvent popupMotionEvent) { + return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayerActivity.java b/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayerActivity.java new file mode 100644 index 000000000..23b814134 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/PopupVideoPlayerActivity.java @@ -0,0 +1,66 @@ +package org.schabi.newpipelegacy.player; + +import android.content.Intent; +import android.view.MenuItem; + +import org.schabi.newpipelegacy.R; + +import static org.schabi.newpipelegacy.player.PopupVideoPlayer.ACTION_CLOSE; + +public final class PopupVideoPlayerActivity extends ServicePlayerActivity { + + private static final String TAG = "PopupVideoPlayerActivity"; + + @Override + public String getTag() { + return TAG; + } + + @Override + public String getSupportActionTitle() { + return getResources().getString(R.string.title_activity_popup_player); + } + + @Override + public Intent getBindIntent() { + return new Intent(this, PopupVideoPlayer.class); + } + + @Override + public void startPlayerListener() { + if (player != null && player instanceof PopupVideoPlayer.VideoPlayerImpl) { + ((PopupVideoPlayer.VideoPlayerImpl) player).setActivityListener(this); + } + } + + @Override + public void stopPlayerListener() { + if (player != null && player instanceof PopupVideoPlayer.VideoPlayerImpl) { + ((PopupVideoPlayer.VideoPlayerImpl) player).removeActivityListener(this); + } + } + + @Override + public int getPlayerOptionMenuResource() { + return R.menu.menu_play_queue_popup; + } + + @Override + public boolean onPlayerOptionSelected(final MenuItem item) { + if (item.getItemId() == R.id.action_switch_background) { + this.player.setRecovery(); + getApplicationContext().sendBroadcast(getPlayerShutdownIntent()); + getApplicationContext().startService( + getSwitchIntent(BackgroundPlayer.class) + .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()) + ); + return true; + } + return false; + } + + @Override + public Intent getPlayerShutdownIntent() { + return new Intent(ACTION_CLOSE); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/ServicePlayerActivity.java b/app/src/main/java/org/schabi/newpipelegacy/player/ServicePlayerActivity.java new file mode 100644 index 000000000..dd2e5e7c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/ServicePlayerActivity.java @@ -0,0 +1,728 @@ +package org.schabi.newpipelegacy.player; + +import android.content.ComponentName; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Bundle; +import android.os.IBinder; +import android.provider.Settings; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipelegacy.local.dialog.PlaylistAppendDialog; +import org.schabi.newpipelegacy.player.event.PlayerEventListener; +import org.schabi.newpipelegacy.player.helper.PlaybackParameterDialog; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueAdapter; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemBuilder; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemHolder; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.NavigationHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.Collections; +import java.util.List; + +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public abstract class ServicePlayerActivity extends AppCompatActivity + implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, + View.OnClickListener, PlaybackParameterDialog.Callback { + private static final int RECYCLER_ITEM_POPUP_MENU_GROUP_ID = 47; + private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; + + protected BasePlayer player; + + private boolean serviceBound; + private ServiceConnection serviceConnection; + + private boolean seeking; + private boolean redraw; + + //////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////// + + private View rootView; + + private RecyclerView itemsList; + private ItemTouchHelper itemTouchHelper; + + private LinearLayout metadata; + private TextView metadataTitle; + private TextView metadataArtist; + + private SeekBar progressSeekBar; + private TextView progressCurrentTime; + private TextView progressEndTime; + private TextView progressLiveSync; + private TextView seekDisplay; + + private ImageButton repeatButton; + private ImageButton backwardButton; + private ImageButton fastRewindButton; + private ImageButton playPauseButton; + private ImageButton fastForwardButton; + private ImageButton forwardButton; + private ImageButton shuffleButton; + private ProgressBar progressBar; + + private Menu menu; + + //////////////////////////////////////////////////////////////////////////// + // Abstracts + //////////////////////////////////////////////////////////////////////////// + + public abstract String getTag(); + + public abstract String getSupportActionTitle(); + + public abstract Intent getBindIntent(); + + public abstract void startPlayerListener(); + + public abstract void stopPlayerListener(); + + public abstract int getPlayerOptionMenuResource(); + + public abstract boolean onPlayerOptionSelected(MenuItem item); + + public abstract Intent getPlayerShutdownIntent(); + //////////////////////////////////////////////////////////////////////////// + // Activity Lifecycle + //////////////////////////////////////////////////////////////////////////// + + @Override + protected void onCreate(final Bundle savedInstanceState) { + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceState); + ThemeHelper.setTheme(this); + setContentView(R.layout.activity_player_queue_control); + rootView = findViewById(R.id.main_content); + + final Toolbar toolbar = rootView.findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + if (getSupportActionBar() != null) { + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + getSupportActionBar().setTitle(getSupportActionTitle()); + } + + serviceConnection = getServiceConnection(); + bind(); + } + + @Override + protected void onResume() { + super.onResume(); + if (redraw) { + recreate(); + redraw = false; + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu m) { + this.menu = m; + getMenuInflater().inflate(R.menu.menu_play_queue, m); + getMenuInflater().inflate(getPlayerOptionMenuResource(), m); + onMaybeMuteChanged(); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + case R.id.action_settings: + NavigationHelper.openSettings(this); + return true; + case R.id.action_append_playlist: + appendAllToPlaylist(); + return true; + case R.id.action_playback_speed: + openPlaybackParameterDialog(); + return true; + case R.id.action_mute: + player.onMuteUnmuteButtonClicked(); + return true; + case R.id.action_system_audio: + startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); + return true; + case R.id.action_switch_main: + this.player.setRecovery(); + getApplicationContext().sendBroadcast(getPlayerShutdownIntent()); + getApplicationContext().startActivity( + getSwitchIntent(MainVideoPlayer.class) + .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()) + ); + return true; + } + return onPlayerOptionSelected(item) || super.onOptionsItemSelected(item); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + unbind(); + } + + protected Intent getSwitchIntent(final Class clazz) { + return NavigationHelper.getPlayerIntent(getApplicationContext(), clazz, + this.player.getPlayQueue(), this.player.getRepeatMode(), + this.player.getPlaybackSpeed(), this.player.getPlaybackPitch(), + this.player.getPlaybackSkipSilence(), null, false, false, this.player.isMuted()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(BasePlayer.START_PAUSED, !this.player.isPlaying()); + } + + //////////////////////////////////////////////////////////////////////////// + // Service Connection + //////////////////////////////////////////////////////////////////////////// + + private void bind() { + final boolean success = bindService(getBindIntent(), serviceConnection, BIND_AUTO_CREATE); + if (!success) { + unbindService(serviceConnection); + } + serviceBound = success; + } + + private void unbind() { + if (serviceBound) { + unbindService(serviceConnection); + serviceBound = false; + stopPlayerListener(); + + if (player != null && player.getPlayQueueAdapter() != null) { + player.getPlayQueueAdapter().unsetSelectedListener(); + } + if (itemsList != null) { + itemsList.setAdapter(null); + } + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + itemsList = null; + itemTouchHelper = null; + player = null; + } + } + + private ServiceConnection getServiceConnection() { + return new ServiceConnection() { + @Override + public void onServiceDisconnected(final ComponentName name) { + Log.d(getTag(), "Player service is disconnected"); + } + + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + Log.d(getTag(), "Player service is connected"); + + if (service instanceof PlayerServiceBinder) { + player = ((PlayerServiceBinder) service).getPlayerInstance(); + } + + if (player == null || player.getPlayQueue() == null + || player.getPlayQueueAdapter() == null || player.getPlayer() == null) { + unbind(); + finish(); + } else { + buildComponents(); + startPlayerListener(); + } + } + }; + } + + //////////////////////////////////////////////////////////////////////////// + // Component Building + //////////////////////////////////////////////////////////////////////////// + + private void buildComponents() { + buildQueue(); + buildMetadata(); + buildSeekBar(); + buildControls(); + } + + private void buildQueue() { + itemsList = findViewById(R.id.play_queue); + itemsList.setLayoutManager(new LinearLayoutManager(this)); + itemsList.setAdapter(player.getPlayQueueAdapter()); + itemsList.setClickable(true); + itemsList.setLongClickable(true); + itemsList.clearOnScrollListeners(); + itemsList.addOnScrollListener(getQueueScrollListener()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(itemsList); + + player.getPlayQueueAdapter().setSelectedListener(getOnSelectedListener()); + } + + private void buildMetadata() { + metadata = rootView.findViewById(R.id.metadata); + metadataTitle = rootView.findViewById(R.id.song_name); + metadataArtist = rootView.findViewById(R.id.artist_name); + + metadata.setOnClickListener(this); + metadataTitle.setSelected(true); + metadataArtist.setSelected(true); + } + + private void buildSeekBar() { + progressCurrentTime = rootView.findViewById(R.id.current_time); + progressSeekBar = rootView.findViewById(R.id.seek_bar); + progressEndTime = rootView.findViewById(R.id.end_time); + progressLiveSync = rootView.findViewById(R.id.live_sync); + seekDisplay = rootView.findViewById(R.id.seek_display); + + progressSeekBar.setOnSeekBarChangeListener(this); + progressLiveSync.setOnClickListener(this); + } + + private void buildControls() { + repeatButton = rootView.findViewById(R.id.control_repeat); + backwardButton = rootView.findViewById(R.id.control_backward); + fastRewindButton = rootView.findViewById(R.id.control_fast_rewind); + playPauseButton = rootView.findViewById(R.id.control_play_pause); + fastForwardButton = rootView.findViewById(R.id.control_fast_forward); + forwardButton = rootView.findViewById(R.id.control_forward); + shuffleButton = rootView.findViewById(R.id.control_shuffle); + progressBar = rootView.findViewById(R.id.control_progress_bar); + + repeatButton.setOnClickListener(this); + backwardButton.setOnClickListener(this); + fastRewindButton.setOnClickListener(this); + playPauseButton.setOnClickListener(this); + fastForwardButton.setOnClickListener(this); + forwardButton.setOnClickListener(this); + shuffleButton.setOnClickListener(this); + } + + private void buildItemPopupMenu(final PlayQueueItem item, final View view) { + final PopupMenu popupMenu = new PopupMenu(this, view); + final MenuItem remove = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 0, + Menu.NONE, R.string.play_queue_remove); + remove.setOnMenuItemClickListener(menuItem -> { + if (player == null) { + return false; + } + + final int index = player.getPlayQueue().indexOf(item); + if (index != -1) { + player.getPlayQueue().remove(index); + } + return true; + }); + + final MenuItem detail = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 1, + Menu.NONE, R.string.play_queue_stream_detail); + detail.setOnMenuItemClickListener(menuItem -> { + onOpenDetail(item.getServiceId(), item.getUrl(), item.getTitle()); + return true; + }); + + final MenuItem append = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 2, + Menu.NONE, R.string.append_playlist); + append.setOnMenuItemClickListener(menuItem -> { + openPlaylistAppendDialog(Collections.singletonList(item)); + return true; + }); + + final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3, + Menu.NONE, R.string.share); + share.setOnMenuItemClickListener(menuItem -> { + shareUrl(item.getTitle(), item.getUrl()); + return true; + }); + + popupMenu.show(); + } + + //////////////////////////////////////////////////////////////////////////// + // Component Helpers + //////////////////////////////////////////////////////////////////////////// + + private OnScrollBelowItemsListener getQueueScrollListener() { + return new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(final RecyclerView recyclerView) { + if (player != null && player.getPlayQueue() != null + && !player.getPlayQueue().isComplete()) { + player.getPlayQueue().fetch(); + } else if (itemsList != null) { + itemsList.clearOnScrollListeners(); + } + } + }; + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new PlayQueueItemTouchCallback() { + @Override + public void onMove(final int sourceIndex, final int targetIndex) { + if (player != null) { + player.getPlayQueue().move(sourceIndex, targetIndex); + } + } + + @Override + public void onSwiped(final int index) { + if (index != -1) { + player.getPlayQueue().remove(index); + } + } + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(final PlayQueueItem item, final View view) { + if (player != null) { + player.onSelected(item); + } + } + + @Override + public void held(final PlayQueueItem item, final View view) { + if (player == null) { + return; + } + + final int index = player.getPlayQueue().indexOf(item); + if (index != -1) { + buildItemPopupMenu(item, view); + } + } + + @Override + public void onStartDrag(final PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) { + itemTouchHelper.startDrag(viewHolder); + } + } + }; + } + + private void onOpenDetail(final int serviceId, final String videoUrl, + final String videoTitle) { + NavigationHelper.openVideoDetail(this, serviceId, videoUrl, videoTitle); + } + + private void scrollToSelected() { + if (player == null) { + return; + } + + final int currentPlayingIndex = player.getPlayQueue().getIndex(); + final int currentVisibleIndex; + if (itemsList.getLayoutManager() instanceof LinearLayoutManager) { + final LinearLayoutManager layout = ((LinearLayoutManager) itemsList.getLayoutManager()); + currentVisibleIndex = layout.findFirstVisibleItemPosition(); + } else { + currentVisibleIndex = 0; + } + + final int distance = Math.abs(currentPlayingIndex - currentVisibleIndex); + if (distance < SMOOTH_SCROLL_MAXIMUM_DISTANCE) { + itemsList.smoothScrollToPosition(currentPlayingIndex); + } else { + itemsList.scrollToPosition(currentPlayingIndex); + } + } + + //////////////////////////////////////////////////////////////////////////// + // Component On-Click Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onClick(final View view) { + if (player == null) { + return; + } + + if (view.getId() == repeatButton.getId()) { + player.onRepeatClicked(); + } else if (view.getId() == backwardButton.getId()) { + player.onPlayPrevious(); + } else if (view.getId() == fastRewindButton.getId()) { + player.onFastRewind(); + } else if (view.getId() == playPauseButton.getId()) { + player.onPlayPause(); + } else if (view.getId() == fastForwardButton.getId()) { + player.onFastForward(); + } else if (view.getId() == forwardButton.getId()) { + player.onPlayNext(); + } else if (view.getId() == shuffleButton.getId()) { + player.onShuffleClicked(); + } else if (view.getId() == metadata.getId()) { + scrollToSelected(); + } else if (view.getId() == progressLiveSync.getId()) { + player.seekToDefault(); + } + } + + //////////////////////////////////////////////////////////////////////////// + // Playback Parameters + //////////////////////////////////////////////////////////////////////////// + + private void openPlaybackParameterDialog() { + if (player == null) { + return; + } + PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(), + player.getPlaybackSkipSilence()).show(getSupportFragmentManager(), getTag()); + } + + @Override + public void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch, + final boolean playbackSkipSilence) { + if (player != null) { + player.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); + } + } + + //////////////////////////////////////////////////////////////////////////// + // Seekbar Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + if (fromUser) { + final String seekTime = Localization.getDurationString(progress / 1000); + progressCurrentTime.setText(seekTime); + seekDisplay.setText(seekTime); + } + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + seeking = true; + seekDisplay.setVisibility(View.VISIBLE); + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + if (player != null) { + player.seekTo(seekBar.getProgress()); + } + seekDisplay.setVisibility(View.GONE); + seeking = false; + } + + //////////////////////////////////////////////////////////////////////////// + // Playlist append + //////////////////////////////////////////////////////////////////////////// + + private void appendAllToPlaylist() { + if (player != null && player.getPlayQueue() != null) { + openPlaylistAppendDialog(player.getPlayQueue().getStreams()); + } + } + + private void openPlaylistAppendDialog(final List playlist) { + PlaylistAppendDialog.fromPlayQueueItems(playlist) + .show(getSupportFragmentManager(), getTag()); + } + + //////////////////////////////////////////////////////////////////////////// + // Share + //////////////////////////////////////////////////////////////////////////// + + private void shareUrl(final String subject, final String url) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_SUBJECT, subject); + intent.putExtra(Intent.EXTRA_TEXT, url); + startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Listener + //////////////////////////////////////////////////////////////////////////// + + @Override + public void onPlaybackUpdate(final int state, final int repeatMode, final boolean shuffled, + final PlaybackParameters parameters) { + onStateChanged(state); + onPlayModeChanged(repeatMode, shuffled); + onPlaybackParameterChanged(parameters); + onMaybePlaybackAdapterChanged(); + onMaybeMuteChanged(); + } + + @Override + public void onProgressUpdate(final int currentProgress, final int duration, + final int bufferPercent) { + // Set buffer progress + progressSeekBar.setSecondaryProgress((int) (progressSeekBar.getMax() + * ((float) bufferPercent / 100))); + + // Set Duration + progressSeekBar.setMax(duration); + progressEndTime.setText(Localization.getDurationString(duration / 1000)); + + // Set current time if not seeking + if (!seeking) { + progressSeekBar.setProgress(currentProgress); + progressCurrentTime.setText(Localization.getDurationString(currentProgress / 1000)); + } + + if (player != null) { + progressLiveSync.setClickable(!player.isLiveEdge()); + } + + // this will make shure progressCurrentTime has the same width as progressEndTime + final ViewGroup.LayoutParams endTimeParams = progressEndTime.getLayoutParams(); + final ViewGroup.LayoutParams currentTimeParams = progressCurrentTime.getLayoutParams(); + currentTimeParams.width = progressEndTime.getWidth(); + progressCurrentTime.setLayoutParams(currentTimeParams); + } + + @Override + public void onMetadataUpdate(final StreamInfo info) { + if (info != null) { + metadataTitle.setText(info.getName()); + metadataArtist.setText(info.getUploaderName()); + + progressEndTime.setVisibility(View.GONE); + progressLiveSync.setVisibility(View.GONE); + switch (info.getStreamType()) { + case LIVE_STREAM: + case AUDIO_LIVE_STREAM: + progressLiveSync.setVisibility(View.VISIBLE); + break; + default: + progressEndTime.setVisibility(View.VISIBLE); + break; + } + + scrollToSelected(); + } + } + + @Override + public void onServiceStopped() { + unbind(); + finish(); + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Helper + //////////////////////////////////////////////////////////////////////////// + + private void onStateChanged(final int state) { + switch (state) { + case BasePlayer.STATE_PAUSED: + playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + break; + case BasePlayer.STATE_PLAYING: + playPauseButton.setImageResource(R.drawable.ic_pause_white_24dp); + break; + case BasePlayer.STATE_COMPLETED: + playPauseButton.setImageResource(R.drawable.ic_replay_white_24dp); + break; + default: + break; + } + + switch (state) { + case BasePlayer.STATE_PAUSED: + case BasePlayer.STATE_PLAYING: + case BasePlayer.STATE_COMPLETED: + playPauseButton.setClickable(true); + playPauseButton.setVisibility(View.VISIBLE); + progressBar.setVisibility(View.GONE); + break; + default: + playPauseButton.setClickable(false); + playPauseButton.setVisibility(View.INVISIBLE); + progressBar.setVisibility(View.VISIBLE); + break; + } + } + + private void onPlayModeChanged(final int repeatMode, final boolean shuffled) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_off); + break; + case Player.REPEAT_MODE_ONE: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_one); + break; + case Player.REPEAT_MODE_ALL: + repeatButton.setImageResource(R.drawable.exo_controls_repeat_all); + break; + } + + final int shuffleAlpha = shuffled ? 255 : 77; + shuffleButton.setImageAlpha(shuffleAlpha); + } + + private void onPlaybackParameterChanged(final PlaybackParameters parameters) { + if (parameters != null) { + if (menu != null && player != null) { + final MenuItem item = menu.findItem(R.id.action_playback_speed); + item.setTitle(formatSpeed(parameters.speed)); + } + } + } + + private void onMaybePlaybackAdapterChanged() { + if (itemsList == null || player == null) { + return; + } + final PlayQueueAdapter maybeNewAdapter = player.getPlayQueueAdapter(); + if (maybeNewAdapter != null && itemsList.getAdapter() != maybeNewAdapter) { + itemsList.setAdapter(maybeNewAdapter); + } + } + + private void onMaybeMuteChanged() { + if (menu != null && player != null) { + MenuItem item = menu.findItem(R.id.action_mute); + + //Change the mute-button item in ActionBar + //1) Text change: + item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute); + + //2) Icon change accordingly to current App Theme + // using rootView.getContext() because getApplicationContext() didn't work + item.setIcon(player.isMuted() + ? ThemeHelper.resolveResourceIdFromAttr(rootView.getContext(), + R.attr.ic_volume_off) + : ThemeHelper.resolveResourceIdFromAttr(rootView.getContext(), + R.attr.ic_volume_up)); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipelegacy/player/VideoPlayer.java new file mode 100644 index 000000000..46a018ebc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/VideoPlayer.java @@ -0,0 +1,1134 @@ +/* + * Copyright 2017 Mauricio Colli + * VideoPlayer.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.player; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.PropertyValuesHolder; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.os.Build; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.view.SurfaceView; +import android.view.View; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.ProgressBar; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.text.CaptionStyleCompat; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; +import com.google.android.exoplayer2.video.VideoListener; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.resolver.MediaSourceTag; +import org.schabi.newpipelegacy.player.resolver.VideoPlaybackResolver; +import org.schabi.newpipelegacy.util.AnimationUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.getTimeString; +import static org.schabi.newpipelegacy.util.AnimationUtils.animateView; + +/** + * Base for video players. + * + * @author mauriciocolli + */ +@SuppressWarnings({"WeakerAccess", "unused"}) +public abstract class VideoPlayer extends BasePlayer + implements VideoListener, + SeekBar.OnSeekBarChangeListener, + View.OnClickListener, + Player.EventListener, + PopupMenu.OnMenuItemClickListener, + PopupMenu.OnDismissListener { + public final String TAG; + public static final boolean DEBUG = BasePlayer.DEBUG; + + /*////////////////////////////////////////////////////////////////////////// + // Player + //////////////////////////////////////////////////////////////////////////*/ + + public static final int DEFAULT_CONTROLS_DURATION = 300; // 300 millis + public static final int DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds + public static final int DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds + + protected static final int RENDERER_UNAVAILABLE = -1; + + @NonNull + private final VideoPlaybackResolver resolver; + + private List availableStreams; + private int selectedStreamIndex; + + protected boolean wasPlaying = false; + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + private View rootView; + + private AspectRatioFrameLayout aspectRatioFrameLayout; + private SurfaceView surfaceView; + private View surfaceForeground; + + private View loadingPanel; + private ImageView endScreen; + private ImageView controlAnimationView; + + private View controlsRoot; + private TextView currentDisplaySeek; + + private View bottomControlsRoot; + private SeekBar playbackSeekBar; + private TextView playbackCurrentTime; + private TextView playbackEndTime; + private TextView playbackLiveSync; + private TextView playbackSpeedTextView; + + private View topControlsRoot; + private TextView qualityTextView; + + private SubtitleView subtitleView; + + private TextView resizeView; + private TextView captionTextView; + + private ValueAnimator controlViewAnimator; + private final Handler controlsVisibilityHandler = new Handler(); + + boolean isSomePopupMenuVisible = false; + + private final int qualityPopupMenuGroupId = 69; + private PopupMenu qualityPopupMenu; + + private final int playbackSpeedPopupMenuGroupId = 79; + private PopupMenu playbackSpeedPopupMenu; + + private final int captionPopupMenuGroupId = 89; + private PopupMenu captionPopupMenu; + + /////////////////////////////////////////////////////////////////////////// + + public VideoPlayer(final String debugTag, final Context context) { + super(context); + this.TAG = debugTag; + this.resolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); + } + + // workaround to match normalized captions like english to English or deutsch to Deutsch + private static boolean containsCaseInsensitive(final List list, final String toFind) { + for (String i : list) { + if (i.equalsIgnoreCase(toFind)) { + return true; + } + } + return false; + } + + public void setup(final View view) { + initViews(view); + setup(); + } + + public void initViews(final View view) { + this.rootView = view; + this.aspectRatioFrameLayout = view.findViewById(R.id.aspectRatioLayout); + this.surfaceView = view.findViewById(R.id.surfaceView); + this.surfaceForeground = view.findViewById(R.id.surfaceForeground); + this.loadingPanel = view.findViewById(R.id.loading_panel); + this.endScreen = view.findViewById(R.id.endScreen); + this.controlAnimationView = view.findViewById(R.id.controlAnimationView); + this.controlsRoot = view.findViewById(R.id.playbackControlRoot); + this.currentDisplaySeek = view.findViewById(R.id.currentDisplaySeek); + this.playbackSeekBar = view.findViewById(R.id.playbackSeekBar); + this.playbackCurrentTime = view.findViewById(R.id.playbackCurrentTime); + this.playbackEndTime = view.findViewById(R.id.playbackEndTime); + this.playbackLiveSync = view.findViewById(R.id.playbackLiveSync); + this.playbackSpeedTextView = view.findViewById(R.id.playbackSpeed); + this.bottomControlsRoot = view.findViewById(R.id.bottomControls); + this.topControlsRoot = view.findViewById(R.id.topControls); + this.qualityTextView = view.findViewById(R.id.qualityTextView); + + this.subtitleView = view.findViewById(R.id.subtitleView); + + final float captionScale = PlayerHelper.getCaptionScale(context); + final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context); + setupSubtitleView(subtitleView, captionScale, captionStyle); + + this.resizeView = view.findViewById(R.id.resizeTextView); + resizeView.setText(PlayerHelper + .resizeTypeOf(context, aspectRatioFrameLayout.getResizeMode())); + + this.captionTextView = view.findViewById(R.id.captionTextView); + + //this.aspectRatioFrameLayout.setAspectRatio(16.0f / 9.0f); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); + } + this.playbackSeekBar.getProgressDrawable(). + setColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY); + + this.qualityPopupMenu = new PopupMenu(context, qualityTextView); + this.playbackSpeedPopupMenu = new PopupMenu(context, playbackSpeedTextView); + this.captionPopupMenu = new PopupMenu(context, captionTextView); + + ((ProgressBar) this.loadingPanel.findViewById(R.id.progressBarLoadingPanel)) + .getIndeterminateDrawable().setColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY); + } + + protected abstract void setupSubtitleView(@NonNull SubtitleView view, float captionScale, + @NonNull CaptionStyleCompat captionStyle); + + @Override + public void initListeners() { + playbackSeekBar.setOnSeekBarChangeListener(this); + playbackSpeedTextView.setOnClickListener(this); + qualityTextView.setOnClickListener(this); + captionTextView.setOnClickListener(this); + resizeView.setOnClickListener(this); + playbackLiveSync.setOnClickListener(this); + } + + @Override + public void initPlayer(final boolean playOnReady) { + super.initPlayer(playOnReady); + + // Setup video view + simpleExoPlayer.setVideoSurfaceView(surfaceView); + simpleExoPlayer.addVideoListener(this); + + // Setup subtitle view + simpleExoPlayer.addTextOutput(cues -> subtitleView.onCues(cues)); + + // Setup audio session with onboard equalizer + if (Build.VERSION.SDK_INT >= 21) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context))); + } + } + + @Override + public void handleIntent(final Intent intent) { + if (intent == null) { + return; + } + + if (intent.hasExtra(PLAYBACK_QUALITY)) { + setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); + } + + super.handleIntent(intent); + } + + /*////////////////////////////////////////////////////////////////////////// + // UI Builders + //////////////////////////////////////////////////////////////////////////*/ + + public void buildQualityMenu() { + if (qualityPopupMenu == null) { + return; + } + + qualityPopupMenu.getMenu().removeGroup(qualityPopupMenuGroupId); + for (int i = 0; i < availableStreams.size(); i++) { + VideoStream videoStream = availableStreams.get(i); + qualityPopupMenu.getMenu().add(qualityPopupMenuGroupId, i, Menu.NONE, MediaFormat + .getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); + } + if (getSelectedVideoStream() != null) { + qualityTextView.setText(getSelectedVideoStream().resolution); + } + qualityPopupMenu.setOnMenuItemClickListener(this); + qualityPopupMenu.setOnDismissListener(this); + } + + private void buildPlaybackSpeedMenu() { + if (playbackSpeedPopupMenu == null) { + return; + } + + playbackSpeedPopupMenu.getMenu().removeGroup(playbackSpeedPopupMenuGroupId); + for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { + playbackSpeedPopupMenu.getMenu().add(playbackSpeedPopupMenuGroupId, i, Menu.NONE, + formatSpeed(PLAYBACK_SPEEDS[i])); + } + playbackSpeedTextView.setText(formatSpeed(getPlaybackSpeed())); + playbackSpeedPopupMenu.setOnMenuItemClickListener(this); + playbackSpeedPopupMenu.setOnDismissListener(this); + } + + private void buildCaptionMenu(final List availableLanguages) { + if (captionPopupMenu == null) { + return; + } + captionPopupMenu.getMenu().removeGroup(captionPopupMenuGroupId); + + String userPreferredLanguage = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.caption_user_set_key), null); + /* + * only search for autogenerated cc as fallback + * if "(auto-generated)" was not already selected + * we are only looking for "(" instead of "(auto-generated)" to hopefully get all + * internationalized variants such as "(automatisch-erzeugt)" and so on + */ + boolean searchForAutogenerated = userPreferredLanguage != null + && !userPreferredLanguage.contains("("); + + // Add option for turning off caption + MenuItem captionOffItem = captionPopupMenu.getMenu().add(captionPopupMenuGroupId, + 0, Menu.NONE, R.string.caption_none); + captionOffItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, true)); + } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().remove(context.getString(R.string.caption_user_set_key)).commit(); + return true; + }); + + // Add all available captions + for (int i = 0; i < availableLanguages.size(); i++) { + final String captionLanguage = availableLanguages.get(i); + MenuItem captionItem = captionPopupMenu.getMenu().add(captionPopupMenuGroupId, + i + 1, Menu.NONE, captionLanguage); + captionItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setPreferredTextLanguage(captionLanguage); + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, false)); + final SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(context); + prefs.edit().putString(context.getString(R.string.caption_user_set_key), + captionLanguage).commit(); + } + return true; + }); + // apply caption language from previous user preference + if (userPreferredLanguage != null && (captionLanguage.equals(userPreferredLanguage) + || searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage) + || userPreferredLanguage.contains("(") && captionLanguage.startsWith( + userPreferredLanguage + .substring(0, userPreferredLanguage.indexOf('('))))) { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setPreferredTextLanguage(captionLanguage); + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, false)); + } + searchForAutogenerated = false; + } + } + captionPopupMenu.setOnDismissListener(this); + } + + private void updateStreamRelatedViews() { + if (getCurrentMetadata() == null) { + return; + } + + final MediaSourceTag tag = getCurrentMetadata(); + final StreamInfo metadata = tag.getMetadata(); + + qualityTextView.setVisibility(View.GONE); + playbackSpeedTextView.setVisibility(View.GONE); + + playbackEndTime.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.GONE); + + switch (metadata.getStreamType()) { + case AUDIO_STREAM: + surfaceView.setVisibility(View.GONE); + endScreen.setVisibility(View.VISIBLE); + playbackEndTime.setVisibility(View.VISIBLE); + break; + + case AUDIO_LIVE_STREAM: + surfaceView.setVisibility(View.GONE); + endScreen.setVisibility(View.VISIBLE); + playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case LIVE_STREAM: + surfaceView.setVisibility(View.VISIBLE); + endScreen.setVisibility(View.GONE); + playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case VIDEO_STREAM: + if (metadata.getVideoStreams().size() + metadata.getVideoOnlyStreams().size() + == 0) { + break; + } + + availableStreams = tag.getSortedAvailableVideoStreams(); + selectedStreamIndex = tag.getSelectedVideoStreamIndex(); + buildQualityMenu(); + + qualityTextView.setVisibility(View.VISIBLE); + surfaceView.setVisibility(View.VISIBLE); + default: + endScreen.setVisibility(View.GONE); + playbackEndTime.setVisibility(View.VISIBLE); + break; + } + + buildPlaybackSpeedMenu(); + playbackSpeedTextView.setVisibility(View.VISIBLE); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Listener + //////////////////////////////////////////////////////////////////////////*/ + + protected abstract VideoPlaybackResolver.QualityResolver getQualityResolver(); + + protected void onMetadataChanged(@NonNull final MediaSourceTag tag) { + super.onMetadataChanged(tag); + updateStreamRelatedViews(); + } + + @Override + @Nullable + public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { + return resolver.resolve(info); + } + + /*////////////////////////////////////////////////////////////////////////// + // States Implementation + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onBlocked() { + super.onBlocked(); + + controlsVisibilityHandler.removeCallbacksAndMessages(null); + animateView(controlsRoot, false, DEFAULT_CONTROLS_DURATION); + + playbackSeekBar.setEnabled(false); + // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, + // so sets the color again + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); + } + + loadingPanel.setBackgroundColor(Color.BLACK); + animateView(loadingPanel, true, 0); + animateView(surfaceForeground, true, 100); + } + + @Override + public void onPlaying() { + super.onPlaying(); + + updateStreamRelatedViews(); + + showAndAnimateControl(-1, true); + + playbackSeekBar.setEnabled(true); + // Bug on lower api, disabling and enabling the seekBar resets the thumb color -.-, + // so sets the color again + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + playbackSeekBar.getThumb().setColorFilter(Color.RED, PorterDuff.Mode.SRC_IN); + } + + loadingPanel.setVisibility(View.GONE); + + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); + } + + @Override + public void onBuffering() { + if (DEBUG) { + Log.d(TAG, "onBuffering() called"); + } + loadingPanel.setBackgroundColor(Color.TRANSPARENT); + } + + @Override + public void onPaused() { + if (DEBUG) { + Log.d(TAG, "onPaused() called"); + } + showControls(400); + loadingPanel.setVisibility(View.GONE); + } + + @Override + public void onPausedSeek() { + if (DEBUG) { + Log.d(TAG, "onPausedSeek() called"); + } + showAndAnimateControl(-1, true); + } + + @Override + public void onCompleted() { + super.onCompleted(); + + showControls(500); + animateView(endScreen, true, 800); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); + loadingPanel.setVisibility(View.GONE); + + animateView(surfaceForeground, true, 100); + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Video Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onTracksChanged(final TrackGroupArray trackGroups, + final TrackSelectionArray trackSelections) { + super.onTracksChanged(trackGroups, trackSelections); + onTextTrackUpdate(); + } + + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + playbackSpeedTextView.setText(formatSpeed(playbackParameters.speed)); + } + + @Override + public void onVideoSizeChanged(final int width, final int height, + final int unappliedRotationDegrees, + final float pixelWidthHeightRatio) { + if (DEBUG) { + Log.d(TAG, "onVideoSizeChanged() called with: " + + "width / height = [" + width + " / " + height + + " = " + (((float) width) / height) + "], " + + "unappliedRotationDegrees = [" + unappliedRotationDegrees + "], " + + "pixelWidthHeightRatio = [" + pixelWidthHeightRatio + "]"); + } + aspectRatioFrameLayout.setAspectRatio(((float) width) / height); + } + + @Override + public void onRenderedFirstFrame() { + animateView(surfaceForeground, false, 100); + } + + /*////////////////////////////////////////////////////////////////////////// + // ExoPlayer Track Updates + //////////////////////////////////////////////////////////////////////////*/ + + private void onTextTrackUpdate() { + final int textRenderer = getRendererIndex(C.TRACK_TYPE_TEXT); + + if (captionTextView == null) { + return; + } + if (trackSelector.getCurrentMappedTrackInfo() == null + || textRenderer == RENDERER_UNAVAILABLE) { + captionTextView.setVisibility(View.GONE); + return; + } + + final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo() + .getTrackGroups(textRenderer); + + // Extract all loaded languages + List availableLanguages = new ArrayList<>(textTracks.length); + for (int i = 0; i < textTracks.length; i++) { + final TrackGroup textTrack = textTracks.get(i); + if (textTrack.length > 0 && textTrack.getFormat(0) != null) { + availableLanguages.add(textTrack.getFormat(0).language); + } + } + + // Normalize mismatching language strings + final String preferredLanguage = trackSelector.getPreferredTextLanguage(); + // Build UI + buildCaptionMenu(availableLanguages); + if (trackSelector.getParameters().getRendererDisabled(textRenderer) + || preferredLanguage == null || (!availableLanguages.contains(preferredLanguage) + && !containsCaseInsensitive(availableLanguages, preferredLanguage))) { + captionTextView.setText(R.string.caption_none); + } else { + captionTextView.setText(preferredLanguage); + } + captionTextView.setVisibility(availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); + } + + /*////////////////////////////////////////////////////////////////////////// + // General Player + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onPrepared(final boolean playWhenReady) { + if (DEBUG) { + Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); + } + + playbackSeekBar.setMax((int) simpleExoPlayer.getDuration()); + playbackEndTime.setText(getTimeString((int) simpleExoPlayer.getDuration())); + playbackSpeedTextView.setText(formatSpeed(getPlaybackSpeed())); + + super.onPrepared(playWhenReady); + + if (simpleExoPlayer.getCurrentPosition() != 0 && !isControlsVisible()) { + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler + .postDelayed(this::showControlsThenHide, DEFAULT_CONTROLS_DURATION); + } + } + + @Override + public void destroy() { + super.destroy(); + if (endScreen != null) { + endScreen.setImageBitmap(null); + } + } + + @Override + public void onUpdateProgress(final int currentProgress, final int duration, + final int bufferPercent) { + if (!isPrepared()) { + return; + } + + if (duration != playbackSeekBar.getMax()) { + playbackEndTime.setText(getTimeString(duration)); + playbackSeekBar.setMax(duration); + } + if (currentState != STATE_PAUSED) { + if (currentState != STATE_PAUSED_SEEK) { + playbackSeekBar.setProgress(currentProgress); + } + playbackCurrentTime.setText(getTimeString(currentProgress)); + } + if (simpleExoPlayer.isLoading() || bufferPercent > 90) { + playbackSeekBar.setSecondaryProgress( + (int) (playbackSeekBar.getMax() * ((float) bufferPercent / 100))); + } + if (DEBUG && bufferPercent % 20 == 0) { //Limit log + Log.d(TAG, "updateProgress() called with: " + + "isVisible = " + isControlsVisible() + ", " + + "currentProgress = [" + currentProgress + "], " + + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); + } + playbackLiveSync.setClickable(!isLiveEdge()); + } + + @Override + public void onLoadingComplete(final String imageUri, final View view, + final Bitmap loadedImage) { + super.onLoadingComplete(imageUri, view, loadedImage); + if (loadedImage != null) { + endScreen.setImageBitmap(loadedImage); + } + } + + protected void onFullScreenButtonClicked() { + changeState(STATE_BLOCKED); + } + + @Override + public void onFastRewind() { + super.onFastRewind(); + showAndAnimateControl(R.drawable.ic_fast_rewind_white_24dp, true); + } + + @Override + public void onFastForward() { + super.onFastForward(); + showAndAnimateControl(R.drawable.ic_fast_forward_white_24dp, true); + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick related + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onClick(final View v) { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + if (v.getId() == qualityTextView.getId()) { + onQualitySelectorClicked(); + } else if (v.getId() == playbackSpeedTextView.getId()) { + onPlaybackSpeedClicked(); + } else if (v.getId() == resizeView.getId()) { + onResizeClicked(); + } else if (v.getId() == captionTextView.getId()) { + onCaptionClicked(); + } else if (v.getId() == playbackLiveSync.getId()) { + seekToDefault(); + } + } + + /** + * Called when an item of the quality selector or the playback speed selector is selected. + */ + @Override + public boolean onMenuItemClick(final MenuItem menuItem) { + if (DEBUG) { + Log.d(TAG, "onMenuItemClick() called with: " + + "menuItem = [" + menuItem + "], " + + "menuItem.getItemId = [" + menuItem.getItemId() + "]"); + } + + if (qualityPopupMenuGroupId == menuItem.getGroupId()) { + final int menuItemIndex = menuItem.getItemId(); + if (selectedStreamIndex == menuItemIndex || availableStreams == null + || availableStreams.size() <= menuItemIndex) { + return true; + } + + final String newResolution = availableStreams.get(menuItemIndex).resolution; + setRecovery(); + setPlaybackQuality(newResolution); + reload(); + + qualityTextView.setText(menuItem.getTitle()); + return true; + } else if (playbackSpeedPopupMenuGroupId == menuItem.getGroupId()) { + int speedIndex = menuItem.getItemId(); + float speed = PLAYBACK_SPEEDS[speedIndex]; + + setPlaybackSpeed(speed); + playbackSpeedTextView.setText(formatSpeed(speed)); + } + + return false; + } + + /** + * Called when some popup menu is dismissed. + */ + @Override + public void onDismiss(final PopupMenu menu) { + if (DEBUG) { + Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); + } + isSomePopupMenuVisible = false; + if (getSelectedVideoStream() != null) { + qualityTextView.setText(getSelectedVideoStream().resolution); + } + } + + public void onQualitySelectorClicked() { + if (DEBUG) { + Log.d(TAG, "onQualitySelectorClicked() called"); + } + qualityPopupMenu.show(); + isSomePopupMenuVisible = true; + showControls(DEFAULT_CONTROLS_DURATION); + + final VideoStream videoStream = getSelectedVideoStream(); + if (videoStream != null) { + final String qualityText = MediaFormat.getNameById(videoStream.getFormatId()) + " " + + videoStream.resolution; + qualityTextView.setText(qualityText); + } + + wasPlaying = simpleExoPlayer.getPlayWhenReady(); + } + + public void onPlaybackSpeedClicked() { + if (DEBUG) { + Log.d(TAG, "onPlaybackSpeedClicked() called"); + } + playbackSpeedPopupMenu.show(); + isSomePopupMenuVisible = true; + showControls(DEFAULT_CONTROLS_DURATION); + } + + private void onCaptionClicked() { + if (DEBUG) { + Log.d(TAG, "onCaptionClicked() called"); + } + captionPopupMenu.show(); + isSomePopupMenuVisible = true; + showControls(DEFAULT_CONTROLS_DURATION); + } + + private void onResizeClicked() { + if (getAspectRatioFrameLayout() != null) { + final int currentResizeMode = getAspectRatioFrameLayout().getResizeMode(); + final int newResizeMode = nextResizeMode(currentResizeMode); + 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 int resizeMode); + + /*////////////////////////////////////////////////////////////////////////// + // SeekBar Listener + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + if (DEBUG && fromUser) { + Log.d(TAG, "onProgressChanged() called with: " + + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); + } + //if (fromUser) playbackCurrentTime.setText(getTimeString(progress)); + if (fromUser) { + currentDisplaySeek.setText(getTimeString(progress)); + } + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + if (getCurrentState() != STATE_PAUSED_SEEK) { + changeState(STATE_PAUSED_SEEK); + } + + wasPlaying = simpleExoPlayer.getPlayWhenReady(); + if (isPlaying()) { + simpleExoPlayer.setPlayWhenReady(false); + } + + showControls(0); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, true, + DEFAULT_CONTROLS_DURATION); + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + + seekTo(seekBar.getProgress()); + if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) { + simpleExoPlayer.setPlayWhenReady(true); + } + + playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); + animateView(currentDisplaySeek, AnimationUtils.Type.SCALE_AND_ALPHA, false, 200); + + if (getCurrentState() == STATE_PAUSED_SEEK) { + changeState(STATE_BUFFERING); + } + if (!isProgressLoopRunning()) { + startProgressLoop(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + public int getRendererIndex(final int trackIndex) { + if (simpleExoPlayer == null) { + return RENDERER_UNAVAILABLE; + } + + for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) { + if (simpleExoPlayer.getRendererType(t) == trackIndex) { + return t; + } + } + + return RENDERER_UNAVAILABLE; + } + + public boolean isControlsVisible() { + return controlsRoot != null && controlsRoot.getVisibility() == View.VISIBLE; + } + + /** + * Show a animation, and depending on goneOnEnd, will stay on the screen or be gone. + * + * @param drawableId the drawable that will be used to animate, + * pass -1 to clear any animation that is visible + * @param goneOnEnd will set the animation view to GONE on the end of the animation + */ + public void showAndAnimateControl(final int drawableId, final boolean goneOnEnd) { + if (DEBUG) { + Log.d(TAG, "showAndAnimateControl() called with: " + + "drawableId = [" + drawableId + "], goneOnEnd = [" + goneOnEnd + "]"); + } + if (controlViewAnimator != null && controlViewAnimator.isRunning()) { + if (DEBUG) { + Log.d(TAG, "showAndAnimateControl: controlViewAnimator.isRunning"); + } + controlViewAnimator.end(); + } + + if (drawableId == -1) { + if (controlAnimationView.getVisibility() == View.VISIBLE) { + controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(controlAnimationView, + PropertyValuesHolder.ofFloat(View.ALPHA, 1f, 0f), + PropertyValuesHolder.ofFloat(View.SCALE_X, 1.4f, 1f), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.4f, 1f) + ).setDuration(DEFAULT_CONTROLS_DURATION); + controlViewAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + controlAnimationView.setVisibility(View.GONE); + } + }); + controlViewAnimator.start(); + } + return; + } + + float scaleFrom = goneOnEnd ? 1f : 1f; + float scaleTo = goneOnEnd ? 1.8f : 1.4f; + float alphaFrom = goneOnEnd ? 1f : 0f; + float alphaTo = goneOnEnd ? 0f : 1f; + + + controlViewAnimator = ObjectAnimator.ofPropertyValuesHolder(controlAnimationView, + PropertyValuesHolder.ofFloat(View.ALPHA, alphaFrom, alphaTo), + PropertyValuesHolder.ofFloat(View.SCALE_X, scaleFrom, scaleTo), + PropertyValuesHolder.ofFloat(View.SCALE_Y, scaleFrom, scaleTo) + ); + controlViewAnimator.setDuration(goneOnEnd ? 1000 : 500); + controlViewAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (goneOnEnd) { + controlAnimationView.setVisibility(View.GONE); + } else { + controlAnimationView.setVisibility(View.VISIBLE); + } + } + }); + + + controlAnimationView.setVisibility(View.VISIBLE); + controlAnimationView.setImageDrawable(AppCompatResources.getDrawable(context, drawableId)); + controlViewAnimator.start(); + } + + public boolean isSomePopupMenuVisible() { + return isSomePopupMenuVisible; + } + + public void showControlsThenHide() { + if (DEBUG) { + Log.d(TAG, "showControlsThenHide() called"); + } + + final int hideTime = controlsRoot.isInTouchMode() + ? DEFAULT_CONTROLS_HIDE_TIME + : DPAD_CONTROLS_HIDE_TIME; + + animateView(controlsRoot, true, DEFAULT_CONTROLS_DURATION, 0, + () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); + } + + public void showControls(final long duration) { + if (DEBUG) { + Log.d(TAG, "showControls() called"); + } + controlsVisibilityHandler.removeCallbacksAndMessages(null); + animateView(controlsRoot, true, duration); + } + + public void safeHideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "safeHideControls() called with: delay = [" + delay + "]"); + } + if (rootView.isInTouchMode()) { + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler.postDelayed( + () -> animateView(controlsRoot, false, duration), delay); + } + } + + public void hideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); + } + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler.postDelayed(() -> + animateView(controlsRoot, false, duration), delay); + } + + public void hideControlsAndButton(final long duration, final long delay, final View button) { + if (DEBUG) { + Log.d(TAG, "hideControls() called with: delay = [" + delay + "]"); + } + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler + .postDelayed(hideControlsAndButtonHandler(duration, button), delay); + } + + private Runnable hideControlsAndButtonHandler(final long duration, final View videoPlayPause) { + return () -> { + videoPlayPause.setVisibility(View.INVISIBLE); + animateView(controlsRoot, false, duration); + }; + } + /*////////////////////////////////////////////////////////////////////////// + // Getters and Setters + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + public String getPlaybackQuality() { + return resolver.getPlaybackQuality(); + } + + public void setPlaybackQuality(final String quality) { + this.resolver.setPlaybackQuality(quality); + } + + public AspectRatioFrameLayout getAspectRatioFrameLayout() { + return aspectRatioFrameLayout; + } + + public SurfaceView getSurfaceView() { + return surfaceView; + } + + public boolean wasPlaying() { + return wasPlaying; + } + + @Nullable + public VideoStream getSelectedVideoStream() { + return (selectedStreamIndex >= 0 && availableStreams != null + && availableStreams.size() > selectedStreamIndex) + ? availableStreams.get(selectedStreamIndex) : null; + } + + public Handler getControlsVisibilityHandler() { + return controlsVisibilityHandler; + } + + public View getRootView() { + return rootView; + } + + public void setRootView(final View rootView) { + this.rootView = rootView; + } + + public View getLoadingPanel() { + return loadingPanel; + } + + public ImageView getEndScreen() { + return endScreen; + } + + public ImageView getControlAnimationView() { + return controlAnimationView; + } + + public View getControlsRoot() { + return controlsRoot; + } + + public View getBottomControlsRoot() { + return bottomControlsRoot; + } + + public SeekBar getPlaybackSeekBar() { + return playbackSeekBar; + } + + public TextView getPlaybackCurrentTime() { + return playbackCurrentTime; + } + + public TextView getPlaybackEndTime() { + return playbackEndTime; + } + + public View getTopControlsRoot() { + return topControlsRoot; + } + + public TextView getQualityTextView() { + return qualityTextView; + } + + public PopupMenu getQualityPopupMenu() { + return qualityPopupMenu; + } + + public PopupMenu getPlaybackSpeedPopupMenu() { + return playbackSpeedPopupMenu; + } + + public View getSurfaceForeground() { + return surfaceForeground; + } + + public TextView getCurrentDisplaySeek() { + return currentDisplaySeek; + } + + public SubtitleView getSubtitleView() { + return subtitleView; + } + + public TextView getResizeView() { + return resizeView; + } + + public TextView getCaptionTextView() { + return captionTextView; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipelegacy/player/event/PlayerEventListener.java new file mode 100644 index 000000000..390a0dcd2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/event/PlayerEventListener.java @@ -0,0 +1,17 @@ +package org.schabi.newpipelegacy.player.event; + + +import com.google.android.exoplayer2.PlaybackParameters; + +import org.schabi.newpipe.extractor.stream.StreamInfo; + +public interface PlayerEventListener { + void onPlaybackUpdate(int state, int repeatMode, boolean shuffled, + PlaybackParameters parameters); + + void onProgressUpdate(int currentProgress, int duration, int bufferPercent); + + void onMetadataUpdate(StreamInfo info); + + void onServiceStopped(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/AudioReactor.java new file mode 100644 index 000000000..e98d56920 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/AudioReactor.java @@ -0,0 +1,175 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.content.Context; +import android.content.Intent; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.audiofx.AudioEffect; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.analytics.AnalyticsListener; + +public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { + + private static final String TAG = "AudioFocusReactor"; + + private static final boolean SHOULD_BUILD_FOCUS_REQUEST = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + + private static final int DUCK_DURATION = 1500; + private static final float DUCK_AUDIO_TO = .2f; + + private static final int FOCUS_GAIN_TYPE = AudioManager.AUDIOFOCUS_GAIN; + private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; + + private final SimpleExoPlayer player; + private final Context context; + private final AudioManager audioManager; + + private final AudioFocusRequest request; + + public AudioReactor(@NonNull final Context context, + @NonNull final SimpleExoPlayer player) { + this.player = player; + this.context = context; + this.audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); + player.addAnalyticsListener(this); + + if (SHOULD_BUILD_FOCUS_REQUEST) { + request = new AudioFocusRequest.Builder(FOCUS_GAIN_TYPE) + .setAcceptsDelayedFocusGain(true) + .setWillPauseWhenDucked(true) + .setOnAudioFocusChangeListener(this) + .build(); + } else { + request = null; + } + } + + public void dispose() { + abandonAudioFocus(); + player.removeAnalyticsListener(this); + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Manager + //////////////////////////////////////////////////////////////////////////*/ + + public void requestAudioFocus() { + if (SHOULD_BUILD_FOCUS_REQUEST) { + audioManager.requestAudioFocus(request); + } else { + audioManager.requestAudioFocus(this, STREAM_TYPE, FOCUS_GAIN_TYPE); + } + } + + public void abandonAudioFocus() { + if (SHOULD_BUILD_FOCUS_REQUEST) { + audioManager.abandonAudioFocusRequest(request); + } else { + audioManager.abandonAudioFocus(this); + } + } + + public int getVolume() { + return audioManager.getStreamVolume(STREAM_TYPE); + } + + public void setVolume(final int volume) { + audioManager.setStreamVolume(STREAM_TYPE, volume, 0); + } + + public int getMaxVolume() { + return audioManager.getStreamMaxVolume(STREAM_TYPE); + } + + /*////////////////////////////////////////////////////////////////////////// + // AudioFocus + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAudioFocusChange(final int focusChange) { + Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]"); + switch (focusChange) { + case AudioManager.AUDIOFOCUS_GAIN: + onAudioFocusGain(); + break; + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: + onAudioFocusLossCanDuck(); + break; + case AudioManager.AUDIOFOCUS_LOSS: + case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: + onAudioFocusLoss(); + break; + } + } + + private void onAudioFocusGain() { + Log.d(TAG, "onAudioFocusGain() called"); + player.setVolume(DUCK_AUDIO_TO); + animateAudio(DUCK_AUDIO_TO, 1f); + + if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { + player.setPlayWhenReady(true); + } + } + + private void onAudioFocusLoss() { + Log.d(TAG, "onAudioFocusLoss() called"); + player.setPlayWhenReady(false); + } + + private void onAudioFocusLossCanDuck() { + Log.d(TAG, "onAudioFocusLossCanDuck() called"); + // Set the volume to 1/10 on ducking + player.setVolume(DUCK_AUDIO_TO); + } + + private void animateAudio(final float from, final float to) { + ValueAnimator valueAnimator = new ValueAnimator(); + valueAnimator.setFloatValues(from, to); + valueAnimator.setDuration(AudioReactor.DUCK_DURATION); + valueAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationStart(final Animator animation) { + player.setVolume(from); + } + + @Override + public void onAnimationCancel(final Animator animation) { + player.setVolume(to); + } + + @Override + public void onAnimationEnd(final Animator animation) { + player.setVolume(to); + } + }); + valueAnimator.addUpdateListener(animation -> + player.setVolume(((float) animation.getAnimatedValue()))); + valueAnimator.start(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Processing + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAudioSessionId(final EventTime eventTime, final int audioSessionId) { + if (!PlayerHelper.isUsingDSP(context)) { + return; + } + + final Intent intent = new Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION); + intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId); + intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); + context.sendBroadcast(intent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/CacheFactory.java new file mode 100644 index 000000000..971f42474 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/CacheFactory.java @@ -0,0 +1,93 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.database.ExoDatabaseProvider; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.FileDataSource; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.upstream.cache.CacheDataSink; +import com.google.android.exoplayer2.upstream.cache.CacheDataSource; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; + +import java.io.File; + +/* package-private */ class CacheFactory implements DataSource.Factory { + private static final String TAG = "CacheFactory"; + + private static final String CACHE_FOLDER_NAME = "exoplayer"; + private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE + | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; + + private final DefaultDataSourceFactory dataSourceFactory; + private final File cacheDir; + private final long maxFileSize; + + // Creating cache on every instance may cause problems with multiple players when + // sources are not ExtractorMediaSource + // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer + // todo: make this a singleton? + private static SimpleCache cache; + + CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), + PlayerHelper.getPreferredFileSize()); + } + + private CacheFactory(@NonNull final Context context, + @NonNull final String userAgent, + @NonNull final TransferListener transferListener, + final long maxCacheSize, + final long maxFileSize) { + this.maxFileSize = maxFileSize; + + dataSourceFactory = new DefaultDataSourceFactory(context, userAgent, transferListener); + cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (!cacheDir.exists()) { + //noinspection ResultOfMethodCallIgnored + cacheDir.mkdir(); + } + + if (cache == null) { + final LeastRecentlyUsedCacheEvictor evictor + = new LeastRecentlyUsedCacheEvictor(maxCacheSize); + cache = new SimpleCache(cacheDir, evictor, new ExoDatabaseProvider(context)); + } + } + + @Override + public DataSource createDataSource() { + Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); + + final DefaultDataSource dataSource = dataSourceFactory.createDataSource(); + final FileDataSource fileSource = new FileDataSource(); + final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); + + return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); + } + + public void tryDeleteCacheFiles() { + if (!cacheDir.exists() || !cacheDir.isDirectory()) { + return; + } + + try { + for (File file : cacheDir.listFiles()) { + final String filePath = file.getAbsolutePath(); + final boolean deleteSuccessful = file.delete(); + + Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); + } + } catch (Exception ignored) { + Log.e(TAG, "Failed to delete file.", ignored); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/LoadController.java new file mode 100644 index 000000000..a205befb2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/LoadController.java @@ -0,0 +1,93 @@ +package org.schabi.newpipelegacy.player.helper; + +import com.google.android.exoplayer2.DefaultLoadControl; +import com.google.android.exoplayer2.LoadControl; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.upstream.Allocator; + +public class LoadController implements LoadControl { + + public static final String TAG = "LoadController"; + + private final long initialPlaybackBufferUs; + private final LoadControl internalLoadControl; + + /*////////////////////////////////////////////////////////////////////////// + // Default Load Control + //////////////////////////////////////////////////////////////////////////*/ + + public LoadController() { + this(PlayerHelper.getPlaybackStartBufferMs(), + PlayerHelper.getPlaybackMinimumBufferMs(), + PlayerHelper.getPlaybackOptimalBufferMs()); + } + + private LoadController(final int initialPlaybackBufferMs, + final int minimumPlaybackbufferMs, + final int optimalPlaybackBufferMs) { + this.initialPlaybackBufferUs = initialPlaybackBufferMs * 1000; + + DefaultLoadControl.Builder builder = new DefaultLoadControl.Builder(); + builder.setBufferDurationsMs(minimumPlaybackbufferMs, optimalPlaybackBufferMs, + initialPlaybackBufferMs, initialPlaybackBufferMs); + internalLoadControl = builder.createDefaultLoadControl(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Custom behaviours + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onPrepared() { + internalLoadControl.onPrepared(); + } + + @Override + public void onTracksSelected(final Renderer[] renderers, final TrackGroupArray trackGroupArray, + final TrackSelectionArray trackSelectionArray) { + internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); + } + + @Override + public void onStopped() { + internalLoadControl.onStopped(); + } + + @Override + public void onReleased() { + internalLoadControl.onReleased(); + } + + @Override + public Allocator getAllocator() { + return internalLoadControl.getAllocator(); + } + + @Override + public long getBackBufferDurationUs() { + return internalLoadControl.getBackBufferDurationUs(); + } + + @Override + public boolean retainBackBufferFromKeyframe() { + return internalLoadControl.retainBackBufferFromKeyframe(); + } + + @Override + public boolean shouldContinueLoading(final long bufferedDurationUs, + final float playbackSpeed) { + return internalLoadControl.shouldContinueLoading(bufferedDurationUs, playbackSpeed); + } + + @Override + public boolean shouldStartPlayback(final long bufferedDurationUs, final float playbackSpeed, + final boolean rebuffering) { + final boolean isInitialPlaybackBufferFilled + = bufferedDurationUs >= this.initialPlaybackBufferUs * playbackSpeed; + final boolean isInternalStartingPlayback = internalLoadControl + .shouldStartPlayback(bufferedDurationUs, playbackSpeed, rebuffering); + return isInitialPlaybackBufferFilled || isInternalStartingPlayback; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/LockManager.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/LockManager.java new file mode 100644 index 000000000..928da769e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/LockManager.java @@ -0,0 +1,56 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.content.Context; +import android.net.wifi.WifiManager; +import android.os.PowerManager; +import android.util.Log; + +import static android.content.Context.POWER_SERVICE; +import static android.content.Context.WIFI_SERVICE; + +public class LockManager { + private final String TAG = "LockManager@" + hashCode(); + + private final PowerManager powerManager; + private final WifiManager wifiManager; + + private PowerManager.WakeLock wakeLock; + private WifiManager.WifiLock wifiLock; + + public LockManager(final Context context) { + powerManager = ((PowerManager) context.getApplicationContext() + .getSystemService(POWER_SERVICE)); + wifiManager = ((WifiManager) context.getApplicationContext() + .getSystemService(WIFI_SERVICE)); + } + + public void acquireWifiAndCpu() { + Log.d(TAG, "acquireWifiAndCpu() called"); + if (wakeLock != null && wakeLock.isHeld() && wifiLock != null && wifiLock.isHeld()) { + return; + } + + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); + wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); + + if (wakeLock != null) { + wakeLock.acquire(); + } + if (wifiLock != null) { + wifiLock.acquire(); + } + } + + public void releaseWifiAndCpu() { + Log.d(TAG, "releaseWifiAndCpu() called"); + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + if (wifiLock != null && wifiLock.isHeld()) { + wifiLock.release(); + } + + wakeLock = null; + wifiLock = null; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/MediaSessionManager.java new file mode 100644 index 000000000..766cfab57 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/MediaSessionManager.java @@ -0,0 +1,94 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; +import android.media.MediaMetadata; +import android.os.Build; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.view.KeyEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationCompat; +import androidx.media.app.NotificationCompat.MediaStyle; +import androidx.media.session.MediaButtonReceiver; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipelegacy.player.mediasession.MediaSessionCallback; +import org.schabi.newpipelegacy.player.mediasession.PlayQueueNavigator; +import org.schabi.newpipelegacy.player.mediasession.PlayQueuePlaybackController; + +public class MediaSessionManager { + private static final String TAG = "MediaSessionManager"; + + @NonNull + private final MediaSessionCompat mediaSession; + @NonNull + private final MediaSessionConnector sessionConnector; + + public MediaSessionManager(@NonNull final Context context, + @NonNull final Player player, + @NonNull final MediaSessionCallback callback) { + this.mediaSession = new MediaSessionCompat(context, TAG); + this.mediaSession.setActive(true); + + this.sessionConnector = new MediaSessionConnector(mediaSession); + this.sessionConnector.setControlDispatcher(new PlayQueuePlaybackController(callback)); + this.sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); + this.sessionConnector.setPlayer(player); + } + + @Nullable + @SuppressWarnings("UnusedReturnValue") + public KeyEvent handleMediaButtonIntent(final Intent intent) { + return MediaButtonReceiver.handleIntent(mediaSession, intent); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void setLockScreenArt(final NotificationCompat.Builder builder, + @Nullable final Bitmap thumbnailBitmap) { + if (thumbnailBitmap == null || !mediaSession.isActive()) { + return; + } + + mediaSession.setMetadata( + new MediaMetadataCompat.Builder() + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, thumbnailBitmap) + .build() + ); + + MediaStyle mediaStyle = new MediaStyle() + .setMediaSession(mediaSession.getSessionToken()); + + builder.setStyle(mediaStyle); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public void clearLockScreenArt(final NotificationCompat.Builder builder) { + mediaSession.setMetadata( + new MediaMetadataCompat.Builder() + .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, null) + .build() + ); + + MediaStyle mediaStyle = new MediaStyle() + .setMediaSession(mediaSession.getSessionToken()); + + builder.setStyle(mediaStyle); + } + + /** + * Should be called on player destruction to prevent leakage. + */ + public void dispose() { + this.sessionConnector.setPlayer(null); + this.sessionConnector.setQueueNavigator(null); + this.mediaSession.setActive(false); + this.mediaSession.release(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlaybackParameterDialog.java new file mode 100644 index 000000000..80ac308c0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlaybackParameterDialog.java @@ -0,0 +1,498 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.util.Log; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.SliderStrategy; + +import static org.schabi.newpipelegacy.player.BasePlayer.DEBUG; +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class PlaybackParameterDialog extends DialogFragment { + // Minimum allowable range in ExoPlayer + private static final double MINIMUM_PLAYBACK_VALUE = 0.10f; + private static final double MAXIMUM_PLAYBACK_VALUE = 3.00f; + + private static final char STEP_UP_SIGN = '+'; + private static final char STEP_DOWN_SIGN = '-'; + + private static final double STEP_ONE_PERCENT_VALUE = 0.01f; + private static final double STEP_FIVE_PERCENT_VALUE = 0.05f; + private static final double STEP_TEN_PERCENT_VALUE = 0.10f; + private static final double STEP_TWENTY_FIVE_PERCENT_VALUE = 0.25f; + private static final double STEP_ONE_HUNDRED_PERCENT_VALUE = 1.00f; + + private static final double DEFAULT_TEMPO = 1.00f; + private static final double DEFAULT_PITCH = 1.00f; + private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE; + private static final boolean DEFAULT_SKIP_SILENCE = false; + + @NonNull + private static final String TAG = "PlaybackParameterDialog"; + @NonNull + private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; + @NonNull + private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; + + @NonNull + private static final String TEMPO_KEY = "tempo_key"; + @NonNull + private static final String PITCH_KEY = "pitch_key"; + @NonNull + private static final String STEP_SIZE_KEY = "step_size_key"; + + @NonNull + private final SliderStrategy strategy = new SliderStrategy.Quadratic( + MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE, + /*centerAt=*/1.00f, /*sliderGranularity=*/10000); + + @Nullable + private Callback callback; + + private double initialTempo = DEFAULT_TEMPO; + private double initialPitch = DEFAULT_PITCH; + private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; + private double tempo = DEFAULT_TEMPO; + private double pitch = DEFAULT_PITCH; + private double stepSize = DEFAULT_STEP; + + @Nullable + private SeekBar tempoSlider; + @Nullable + private TextView tempoCurrentText; + @Nullable + private TextView tempoStepDownText; + @Nullable + private TextView tempoStepUpText; + @Nullable + private SeekBar pitchSlider; + @Nullable + private TextView pitchCurrentText; + @Nullable + private TextView pitchStepDownText; + @Nullable + private TextView pitchStepUpText; + @Nullable + private CheckBox unhookingCheckbox; + @Nullable + private CheckBox skipSilenceCheckbox; + + public static PlaybackParameterDialog newInstance(final double playbackTempo, + final double playbackPitch, + final boolean playbackSkipSilence) { + PlaybackParameterDialog dialog = new PlaybackParameterDialog(); + dialog.initialTempo = playbackTempo; + dialog.initialPitch = playbackPitch; + + dialog.tempo = playbackTempo; + dialog.pitch = playbackPitch; + + dialog.initialSkipSilence = playbackSkipSilence; + return dialog; + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + if (context != null && context instanceof Callback) { + callback = (Callback) context; + } else { + dismiss(); + } + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + assureCorrectAppLanguage(getContext()); + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); + initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); + + tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO); + pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH); + stepSize = savedInstanceState.getDouble(STEP_SIZE_KEY, DEFAULT_STEP); + } + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); + outState.putDouble(INITIAL_PITCH_KEY, initialPitch); + + outState.putDouble(TEMPO_KEY, getCurrentTempo()); + outState.putDouble(PITCH_KEY, getCurrentPitch()); + outState.putDouble(STEP_SIZE_KEY, getCurrentStepSize()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Dialog + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { + assureCorrectAppLanguage(getContext()); + final View view = View.inflate(getContext(), R.layout.dialog_playback_parameter, null); + setupControlViews(view); + + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) + .setTitle(R.string.playback_speed_control) + .setView(view) + .setCancelable(true) + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> + setPlaybackParameters(initialTempo, initialPitch, initialSkipSilence)) + .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> + setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, DEFAULT_SKIP_SILENCE)) + .setPositiveButton(R.string.finish, (dialogInterface, i) -> + setCurrentPlaybackParameters()); + + return dialogBuilder.create(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Control Views + //////////////////////////////////////////////////////////////////////////*/ + + private void setupControlViews(@NonNull final View rootView) { + setupHookingControl(rootView); + setupSkipSilenceControl(rootView); + + setupTempoControl(rootView); + setupPitchControl(rootView); + + setStepSize(stepSize); + setupStepSizeSelector(rootView); + } + + private void setupTempoControl(@NonNull final View rootView) { + tempoSlider = rootView.findViewById(R.id.tempoSeekbar); + TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); + TextView tempoMaximumText = rootView.findViewById(R.id.tempoMaximumText); + tempoCurrentText = rootView.findViewById(R.id.tempoCurrentText); + tempoStepUpText = rootView.findViewById(R.id.tempoStepUp); + tempoStepDownText = rootView.findViewById(R.id.tempoStepDown); + + if (tempoCurrentText != null) { + tempoCurrentText.setText(PlayerHelper.formatSpeed(tempo)); + } + if (tempoMaximumText != null) { + tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); + } + if (tempoMinimumText != null) { + tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); + } + + if (tempoSlider != null) { + tempoSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + tempoSlider.setProgress(strategy.progressOf(tempo)); + tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); + } + } + + private void setupPitchControl(@NonNull final View rootView) { + pitchSlider = rootView.findViewById(R.id.pitchSeekbar); + TextView pitchMinimumText = rootView.findViewById(R.id.pitchMinimumText); + TextView pitchMaximumText = rootView.findViewById(R.id.pitchMaximumText); + pitchCurrentText = rootView.findViewById(R.id.pitchCurrentText); + pitchStepDownText = rootView.findViewById(R.id.pitchStepDown); + pitchStepUpText = rootView.findViewById(R.id.pitchStepUp); + + if (pitchCurrentText != null) { + pitchCurrentText.setText(PlayerHelper.formatPitch(pitch)); + } + if (pitchMaximumText != null) { + pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); + } + if (pitchMinimumText != null) { + pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); + } + + if (pitchSlider != null) { + pitchSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); + pitchSlider.setProgress(strategy.progressOf(pitch)); + pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); + } + } + + private void setupHookingControl(@NonNull final View rootView) { + unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); + if (unhookingCheckbox != null) { + // restore whether pitch and tempo are unhooked or not + unhookingCheckbox.setChecked(PreferenceManager.getDefaultSharedPreferences(getContext()) + .getBoolean(getString(R.string.playback_unhook_key), true)); + + unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + // save whether pitch and tempo are unhooked or not + PreferenceManager.getDefaultSharedPreferences(getContext()) + .edit() + .putBoolean(getString(R.string.playback_unhook_key), isChecked) + .apply(); + + if (!isChecked) { + // when unchecked, slide back to the minimum of current tempo or pitch + final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); + setSliders(minimum); + setCurrentPlaybackParameters(); + } + }); + } + } + + private void setupSkipSilenceControl(@NonNull final View rootView) { + skipSilenceCheckbox = rootView.findViewById(R.id.skipSilenceCheckbox); + if (skipSilenceCheckbox != null) { + skipSilenceCheckbox.setChecked(initialSkipSilence); + skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> + setCurrentPlaybackParameters()); + } + } + + private void setupStepSizeSelector(@NonNull final View rootView) { + TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent); + TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent); + TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent); + TextView stepSizeTwentyFivePercentText = rootView + .findViewById(R.id.stepSizeTwentyFivePercent); + TextView stepSizeOneHundredPercentText = rootView + .findViewById(R.id.stepSizeOneHundredPercent); + + if (stepSizeOnePercentText != null) { + stepSizeOnePercentText.setText(getPercentString(STEP_ONE_PERCENT_VALUE)); + stepSizeOnePercentText + .setOnClickListener(view -> setStepSize(STEP_ONE_PERCENT_VALUE)); + } + + if (stepSizeFivePercentText != null) { + stepSizeFivePercentText.setText(getPercentString(STEP_FIVE_PERCENT_VALUE)); + stepSizeFivePercentText + .setOnClickListener(view -> setStepSize(STEP_FIVE_PERCENT_VALUE)); + } + + if (stepSizeTenPercentText != null) { + stepSizeTenPercentText.setText(getPercentString(STEP_TEN_PERCENT_VALUE)); + stepSizeTenPercentText + .setOnClickListener(view -> setStepSize(STEP_TEN_PERCENT_VALUE)); + } + + if (stepSizeTwentyFivePercentText != null) { + stepSizeTwentyFivePercentText + .setText(getPercentString(STEP_TWENTY_FIVE_PERCENT_VALUE)); + stepSizeTwentyFivePercentText + .setOnClickListener(view -> setStepSize(STEP_TWENTY_FIVE_PERCENT_VALUE)); + } + + if (stepSizeOneHundredPercentText != null) { + stepSizeOneHundredPercentText + .setText(getPercentString(STEP_ONE_HUNDRED_PERCENT_VALUE)); + stepSizeOneHundredPercentText + .setOnClickListener(view -> setStepSize(STEP_ONE_HUNDRED_PERCENT_VALUE)); + } + } + + private void setStepSize(final double stepSize) { + this.stepSize = stepSize; + + if (tempoStepUpText != null) { + tempoStepUpText.setText(getStepUpPercentString(stepSize)); + tempoStepUpText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() + stepSize); + setCurrentPlaybackParameters(); + }); + } + + if (tempoStepDownText != null) { + tempoStepDownText.setText(getStepDownPercentString(stepSize)); + tempoStepDownText.setOnClickListener(view -> { + onTempoSliderUpdated(getCurrentTempo() - stepSize); + setCurrentPlaybackParameters(); + }); + } + + if (pitchStepUpText != null) { + pitchStepUpText.setText(getStepUpPercentString(stepSize)); + pitchStepUpText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() + stepSize); + setCurrentPlaybackParameters(); + }); + } + + if (pitchStepDownText != null) { + pitchStepDownText.setText(getStepDownPercentString(stepSize)); + pitchStepDownText.setOnClickListener(view -> { + onPitchSliderUpdated(getCurrentPitch() - stepSize); + setCurrentPlaybackParameters(); + }); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Sliders + //////////////////////////////////////////////////////////////////////////*/ + + private SeekBar.OnSeekBarChangeListener getOnTempoChangedListener() { + return new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + final double currentTempo = strategy.valueOf(progress); + if (fromUser) { + onTempoSliderUpdated(currentTempo); + setCurrentPlaybackParameters(); + } + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + // Do Nothing. + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + // Do Nothing. + } + }; + } + + private SeekBar.OnSeekBarChangeListener getOnPitchChangedListener() { + return new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + final double currentPitch = strategy.valueOf(progress); + if (fromUser) { // this change is first in chain + onPitchSliderUpdated(currentPitch); + setCurrentPlaybackParameters(); + } + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + // Do Nothing. + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + // Do Nothing. + } + }; + } + + private void onTempoSliderUpdated(final double newTempo) { + if (unhookingCheckbox == null) { + return; + } + if (!unhookingCheckbox.isChecked()) { + setSliders(newTempo); + } else { + setTempoSlider(newTempo); + } + } + + private void onPitchSliderUpdated(final double newPitch) { + if (unhookingCheckbox == null) { + return; + } + if (!unhookingCheckbox.isChecked()) { + setSliders(newPitch); + } else { + setPitchSlider(newPitch); + } + } + + private void setSliders(final double newValue) { + setTempoSlider(newValue); + setPitchSlider(newValue); + } + + private void setTempoSlider(final double newTempo) { + if (tempoSlider == null) { + return; + } + tempoSlider.setProgress(strategy.progressOf(newTempo)); + } + + private void setPitchSlider(final double newPitch) { + if (pitchSlider == null) { + return; + } + pitchSlider.setProgress(strategy.progressOf(newPitch)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Helper + //////////////////////////////////////////////////////////////////////////*/ + + private void setCurrentPlaybackParameters() { + setPlaybackParameters(getCurrentTempo(), getCurrentPitch(), getCurrentSkipSilence()); + } + + private void setPlaybackParameters(final double newTempo, final double newPitch, + final boolean skipSilence) { + if (callback != null && tempoCurrentText != null && pitchCurrentText != null) { + if (DEBUG) { + Log.d(TAG, "Setting playback parameters to " + + "tempo=[" + newTempo + "], " + + "pitch=[" + newPitch + "]"); + } + + tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo)); + pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch)); + callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence); + } + } + + private double getCurrentTempo() { + return tempoSlider == null ? tempo : strategy.valueOf(tempoSlider.getProgress()); + } + + private double getCurrentPitch() { + return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress()); + } + + private double getCurrentStepSize() { + return stepSize; + } + + private boolean getCurrentSkipSilence() { + return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked(); + } + + @NonNull + private static String getStepUpPercentString(final double percent) { + return STEP_UP_SIGN + getPercentString(percent); + } + + @NonNull + private static String getStepDownPercentString(final double percent) { + return STEP_DOWN_SIGN + getPercentString(percent); + } + + @NonNull + private static String getPercentString(final double percent) { + return PlayerHelper.formatPitch(percent); + } + + public interface Callback { + void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, + boolean playbackSkipSilence); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerDataSource.java new file mode 100644 index 000000000..09bdab3f7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerDataSource.java @@ -0,0 +1,85 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.SingleSampleMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; +import com.google.android.exoplayer2.upstream.TransferListener; + +public class PlayerDataSource { + private static final int MANIFEST_MINIMUM_RETRY = 5; + private static final int EXTRACTOR_MINIMUM_RETRY = Integer.MAX_VALUE; + private static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; + + private final DataSource.Factory cacheDataSourceFactory; + private final DataSource.Factory cachelessDataSourceFactory; + + public PlayerDataSource(@NonNull final Context context, @NonNull final String userAgent, + @NonNull final TransferListener transferListener) { + cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); + cachelessDataSourceFactory + = new DefaultDataSourceFactory(context, userAgent, transferListener); + } + + public SsMediaSource.Factory getLiveSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setLoadErrorHandlingPolicy( + new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + } + + public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cachelessDataSourceFactory) + .setAllowChunklessPreparation(true) + .setLoadErrorHandlingPolicy( + new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)); + } + + public DashMediaSource.Factory getLiveDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cachelessDataSourceFactory), cachelessDataSourceFactory) + .setLoadErrorHandlingPolicy( + new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)) + .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS, true); + } + + public SsMediaSource.Factory getSsMediaSourceFactory() { + return new SsMediaSource.Factory(new DefaultSsChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public HlsMediaSource.Factory getHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(cacheDataSourceFactory); + } + + public DashMediaSource.Factory getDashMediaSourceFactory() { + return new DashMediaSource.Factory(new DefaultDashChunkSource.Factory( + cacheDataSourceFactory), cacheDataSourceFactory); + } + + public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) + .setLoadErrorHandlingPolicy( + new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + } + + public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory( + @NonNull final String key) { + return getExtractorMediaSourceFactory().setCustomCacheKey(key); + } + + public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerHelper.java new file mode 100644 index 000000000..21c57aada --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/helper/PlayerHelper.java @@ -0,0 +1,414 @@ +package org.schabi.newpipelegacy.player.helper; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.preference.PreferenceManager; +import android.view.accessibility.CaptioningManager; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.SeekParameters; +import com.google.android.exoplayer2.text.CaptionStyleCompat; +import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.util.MimeTypes; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.InfoItem; +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.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; + +import java.lang.annotation.Retention; +import java.text.DecimalFormat; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Formatter; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL; +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.newpipelegacy.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; +import static org.schabi.newpipelegacy.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; + +public final class PlayerHelper { + private static final StringBuilder STRING_BUILDER = new StringBuilder(); + private static final Formatter STRING_FORMATTER + = new Formatter(STRING_BUILDER, Locale.getDefault()); + private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); + private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); + + private PlayerHelper() { } + + //////////////////////////////////////////////////////////////////////////// + // Exposed helpers + //////////////////////////////////////////////////////////////////////////// + + public static String getTimeString(final int milliSeconds) { + int seconds = (milliSeconds % 60000) / 1000; + int minutes = (milliSeconds % 3600000) / 60000; + int hours = (milliSeconds % 86400000) / 3600000; + int days = (milliSeconds % (86400000 * 7)) / 86400000; + + STRING_BUILDER.setLength(0); + return days > 0 + ? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds) + .toString() + : hours > 0 + ? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds).toString() + : STRING_FORMATTER.format("%02d:%02d", minutes, seconds).toString(); + } + + public static String formatSpeed(final double speed) { + return SPEED_FORMATTER.format(speed); + } + + public static String formatPitch(final double pitch) { + return PITCH_FORMATTER.format(pitch); + } + + public static String subtitleMimeTypesOf(final MediaFormat format) { + switch (format) { + case VTT: + return MimeTypes.TEXT_VTT; + case TTML: + return MimeTypes.APPLICATION_TTML; + default: + throw new IllegalArgumentException("Unrecognized mime type: " + format.name()); + } + } + + @NonNull + public static String captionLanguageOf(@NonNull final Context context, + @NonNull final SubtitlesStream subtitles) { + final String displayName = subtitles.getDisplayLanguageName(); + return displayName + (subtitles.isAutoGenerated() + ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); + } + + @NonNull + public static String resizeTypeOf(@NonNull final Context context, + @AspectRatioFrameLayout.ResizeMode final int resizeMode) { + switch (resizeMode) { + case RESIZE_MODE_FIT: + return context.getResources().getString(R.string.resize_fit); + case RESIZE_MODE_FILL: + return context.getResources().getString(R.string.resize_fill); + case RESIZE_MODE_ZOOM: + return context.getResources().getString(R.string.resize_zoom); + default: + throw new IllegalArgumentException("Unrecognized resize mode: " + resizeMode); + } + } + + @NonNull + public static String cacheKeyOf(@NonNull final StreamInfo info, + @NonNull final VideoStream video) { + return info.getUrl() + video.getResolution() + video.getFormat().getName(); + } + + @NonNull + public static String cacheKeyOf(@NonNull final StreamInfo info, + @NonNull final AudioStream audio) { + return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); + } + + /** + * Given a {@link StreamInfo} and the existing queue items, + * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. + *

+ * This method detects and prevents cycles by naively checking + * if a candidate next video's url already exists in the existing items. + *

+ *

+ * The first item in {@link StreamInfo#getRelatedStreams()} is checked first. + * If it is non-null and is not part of the existing items, it will be used as the next stream. + * Otherwise, a random item with non-repeating url will be selected + * from the {@link StreamInfo#getRelatedStreams()}. + *

+ * + * @param info currently playing stream + * @param existingItems existing items in the queue + * @return {@link SinglePlayQueue} with the next stream to queue + */ + @Nullable + public static PlayQueue autoQueueOf(@NonNull final StreamInfo info, + @NonNull final List existingItems) { + final Set urls = new HashSet<>(existingItems.size()); + for (final PlayQueueItem item : existingItems) { + urls.add(item.getUrl()); + } + + final List relatedItems = info.getRelatedStreams(); + if (relatedItems == null) { + return null; + } + + if (relatedItems.get(0) != null && relatedItems.get(0) instanceof StreamInfoItem + && !urls.contains(relatedItems.get(0).getUrl())) { + return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); + } + + final List autoQueueItems = new ArrayList<>(); + for (final InfoItem item : relatedItems) { + if (item instanceof StreamInfoItem && !urls.contains(item.getUrl())) { + autoQueueItems.add((StreamInfoItem) item); + } + } + + Collections.shuffle(autoQueueItems); + return autoQueueItems.isEmpty() + ? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)); + } + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + //////////////////////////////////////////////////////////////////////////// + + public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { + return isResumeAfterAudioFocusGain(context, false); + } + + public static boolean isVolumeGestureEnabled(@NonNull final Context context) { + return isVolumeGestureEnabled(context, true); + } + + public static boolean isBrightnessGestureEnabled(@NonNull final Context context) { + return isBrightnessGestureEnabled(context, true); + } + + public static boolean isRememberingPopupDimensions(@NonNull final Context context) { + return isRememberingPopupDimensions(context, true); + } + + public static boolean isAutoQueueEnabled(@NonNull final Context context) { + return isAutoQueueEnabled(context, false); + } + + @MinimizeMode + public static int getMinimizeOnExitAction(@NonNull final Context context) { + final String defaultAction = context.getString(R.string.minimize_on_exit_none_key); + final String popupAction = context.getString(R.string.minimize_on_exit_popup_key); + final String backgroundAction = context.getString(R.string.minimize_on_exit_background_key); + + final String action = getMinimizeOnExitAction(context, defaultAction); + if (action.equals(popupAction)) { + return MINIMIZE_ON_EXIT_MODE_POPUP; + } else if (action.equals(backgroundAction)) { + return MINIMIZE_ON_EXIT_MODE_BACKGROUND; + } else { + return MINIMIZE_ON_EXIT_MODE_NONE; + } + } + + @NonNull + public static SeekParameters getSeekParameters(@NonNull final Context context) { + return isUsingInexactSeek(context) ? SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; + } + + public static long getPreferredCacheSize() { + return 64 * 1024 * 1024L; + } + + public static long getPreferredFileSize() { + return 512 * 1024L; + } + + /** + * @return the number of milliseconds the player buffers for before starting playback + */ + public static int getPlaybackStartBufferMs() { + return 500; + } + + /** + * @return the minimum number of milliseconds the player always buffers to + * after starting playback. + */ + public static int getPlaybackMinimumBufferMs() { + return 25000; + } + + /** + * @return the maximum/optimal number of milliseconds the player will buffer to once the buffer + * hits the point of {@link #getPlaybackMinimumBufferMs()}. + */ + public static int getPlaybackOptimalBufferMs() { + return 60000; + } + + public static TrackSelection.Factory getQualitySelector(@NonNull final Context context) { + return new AdaptiveTrackSelection.Factory( + 1000, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION); + } + + public static boolean isUsingDSP(@NonNull final Context context) { + return true; + } + + public static int getTossFlingVelocity(@NonNull final Context context) { + return 2500; + } + + @NonNull + public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return CaptionStyleCompat.DEFAULT; + } + + final CaptioningManager captioningManager = (CaptioningManager) + context.getSystemService(Context.CAPTIONING_SERVICE); + if (captioningManager == null || !captioningManager.isEnabled()) { + return CaptionStyleCompat.DEFAULT; + } + + return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); + } + + /** + * Get scaling for captions based on system font scaling. + *

Options:

+ *
    + *
  • Very small: 0.25f
  • + *
  • Small: 0.5f
  • + *
  • Normal: 1.0f
  • + *
  • Large: 1.5f
  • + *
  • Very large: 2.0f
  • + *
+ * + * @param context Android app context + * @return caption scaling + */ + public static float getCaptionScale(@NonNull final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return 1f; + } + + final CaptioningManager captioningManager + = (CaptioningManager) context.getSystemService(Context.CAPTIONING_SERVICE); + if (captioningManager == null || !captioningManager.isEnabled()) { + return 1f; + } + + return captioningManager.getFontScale(); + } + + public static float getScreenBrightness(@NonNull final Context context) { + //a value of less than 0, the default, means to use the preferred screen brightness + return getScreenBrightness(context, -1); + } + + public static void setScreenBrightness(@NonNull final Context context, + final float setScreenBrightness) { + setScreenBrightness(context, setScreenBrightness, System.currentTimeMillis()); + } + + //////////////////////////////////////////////////////////////////////////// + // Private helpers + //////////////////////////////////////////////////////////////////////////// + + @NonNull + private static SharedPreferences getPreferences(@NonNull final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context); + } + + private static boolean isResumeAfterAudioFocusGain(@NonNull final Context context, + final boolean b) { + return getPreferences(context) + .getBoolean(context.getString(R.string.resume_on_audio_focus_gain_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 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) { + return getPreferences(context) + .getBoolean(context.getString(R.string.popup_remember_size_pos_key), b); + } + + private static boolean isUsingInexactSeek(@NonNull final Context context) { + return getPreferences(context) + .getBoolean(context.getString(R.string.use_inexact_seek_key), false); + } + + private static boolean isAutoQueueEnabled(@NonNull final Context context, final boolean b) { + return getPreferences(context).getBoolean(context.getString(R.string.auto_queue_key), b); + } + + private static void setScreenBrightness(@NonNull final Context context, + final float screenBrightness, final long timestamp) { + SharedPreferences.Editor editor = getPreferences(context).edit(); + editor.putFloat(context.getString(R.string.screen_brightness_key), screenBrightness); + editor.putLong(context.getString(R.string.screen_brightness_timestamp_key), timestamp); + editor.apply(); + } + + private static float getScreenBrightness(@NonNull final Context context, + final float screenBrightness) { + SharedPreferences sp = getPreferences(context); + long timestamp = sp + .getLong(context.getString(R.string.screen_brightness_timestamp_key), 0); + // Hypothesis: 4h covers a viewing block, e.g. evening. + // External lightning conditions will change in the next + // viewing block so we fall back to the default brightness + if ((System.currentTimeMillis() - timestamp) > TimeUnit.HOURS.toMillis(4)) { + return screenBrightness; + } else { + return sp + .getFloat(context.getString(R.string.screen_brightness_key), screenBrightness); + } + } + + private static String getMinimizeOnExitAction(@NonNull final Context context, + final String key) { + return getPreferences(context) + .getString(context.getString(R.string.minimize_on_exit_key), key); + } + + private static SinglePlayQueue getAutoQueuedSinglePlayQueue( + final StreamInfoItem streamInfoItem) { + SinglePlayQueue singlePlayQueue = new SinglePlayQueue(streamInfoItem); + singlePlayQueue.getItem().setAutoQueued(true); + return singlePlayQueue; + } + + @Retention(SOURCE) + @IntDef({MINIMIZE_ON_EXIT_MODE_NONE, MINIMIZE_ON_EXIT_MODE_BACKGROUND, + MINIMIZE_ON_EXIT_MODE_POPUP}) + public @interface MinimizeMode { + int MINIMIZE_ON_EXIT_MODE_NONE = 0; + int MINIMIZE_ON_EXIT_MODE_BACKGROUND = 1; + int MINIMIZE_ON_EXIT_MODE_POPUP = 2; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/MediaSessionCallback.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/MediaSessionCallback.java new file mode 100644 index 000000000..67e3c0f3e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/MediaSessionCallback.java @@ -0,0 +1,21 @@ +package org.schabi.newpipelegacy.player.mediasession; + +import android.support.v4.media.MediaDescriptionCompat; + +public interface MediaSessionCallback { + void onSkipToPrevious(); + + void onSkipToNext(); + + void onSkipToIndex(int index); + + int getCurrentPlayingIndex(); + + int getQueueSize(); + + MediaDescriptionCompat getQueueMetadata(int index); + + void onPlay(); + + void onPause(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueueNavigator.java new file mode 100644 index 000000000..11b6d01f7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueueNavigator.java @@ -0,0 +1,109 @@ +package org.schabi.newpipelegacy.player.mediasession; + +import android.os.Bundle; +import android.os.ResultReceiver; +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ControlDispatcher; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.util.Util; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; +import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; + +public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { + public static final int DEFAULT_MAX_QUEUE_SIZE = 10; + + private final MediaSessionCompat mediaSession; + private final MediaSessionCallback callback; + private final int maxQueueSize; + + private long activeQueueItemId; + + public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, + @NonNull final MediaSessionCallback callback) { + this.mediaSession = mediaSession; + this.callback = callback; + this.maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; + + this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; + } + + @Override + public long getSupportedQueueNavigatorActions(@Nullable final Player player) { + return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; + } + + @Override + public void onTimelineChanged(final Player player) { + publishFloatingQueueWindow(); + } + + @Override + public void onCurrentWindowIndexChanged(final Player player) { + if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID + || player.getCurrentTimeline().getWindowCount() > maxQueueSize) { + publishFloatingQueueWindow(); + } else if (!player.getCurrentTimeline().isEmpty()) { + activeQueueItemId = player.getCurrentWindowIndex(); + } + } + + @Override + public long getActiveQueueItemId(@Nullable final Player player) { + return callback.getCurrentPlayingIndex(); + } + + @Override + public void onSkipToPrevious(final Player player, final ControlDispatcher controlDispatcher) { + callback.onSkipToPrevious(); + } + + @Override + public void onSkipToQueueItem(final Player player, final ControlDispatcher controlDispatcher, + final long id) { + callback.onSkipToIndex((int) id); + } + + @Override + public void onSkipToNext(final Player player, final ControlDispatcher controlDispatcher) { + callback.onSkipToNext(); + } + + private void publishFloatingQueueWindow() { + if (callback.getQueueSize() == 0) { + mediaSession.setQueue(Collections.emptyList()); + activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; + return; + } + + // Yes this is almost a copypasta, got a problem with that? =\ + int windowCount = callback.getQueueSize(); + int currentWindowIndex = callback.getCurrentPlayingIndex(); + int queueSize = Math.min(maxQueueSize, windowCount); + int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, + windowCount - queueSize); + + List queue = new ArrayList<>(); + for (int i = startIndex; i < startIndex + queueSize; i++) { + queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i)); + } + mediaSession.setQueue(queue); + activeQueueItemId = currentWindowIndex; + } + + @Override + public boolean onCommand(final Player player, final ControlDispatcher controlDispatcher, + final String command, final Bundle extras, final ResultReceiver cb) { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueuePlaybackController.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueuePlaybackController.java new file mode 100644 index 000000000..17ac83fd1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasession/PlayQueuePlaybackController.java @@ -0,0 +1,23 @@ +package org.schabi.newpipelegacy.player.mediasession; + +import com.google.android.exoplayer2.DefaultControlDispatcher; +import com.google.android.exoplayer2.Player; + +public class PlayQueuePlaybackController extends DefaultControlDispatcher { + private final MediaSessionCallback callback; + + public PlayQueuePlaybackController(final MediaSessionCallback callback) { + super(); + this.callback = callback; + } + + @Override + public boolean dispatchSetPlayWhenReady(final Player player, final boolean playWhenReady) { + if (playWhenReady) { + callback.onPlay(); + } else { + callback.onPause(); + } + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/FailedMediaSource.java new file mode 100644 index 000000000..be0ed2f6b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/FailedMediaSource.java @@ -0,0 +1,111 @@ +package org.schabi.newpipelegacy.player.mediasource; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.BaseMediaSource; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +import java.io.IOException; + +public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource { + private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); + private final PlayQueueItem playQueueItem; + private final FailedMediaSourceException error; + private final long retryTimestamp; + + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final FailedMediaSourceException error, + final long retryTimestamp) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = retryTimestamp; + } + + /** + * Permanently fail the play queue item associated with this source, with no hope of retrying. + * The error will always be propagated to ExoPlayer. + * + * @param playQueueItem play queue item + * @param error exception that was the reason to fail + */ + public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, + @NonNull final FailedMediaSourceException error) { + this.playQueueItem = playQueueItem; + this.error = error; + this.retryTimestamp = Long.MAX_VALUE; + } + + public PlayQueueItem getStream() { + return playQueueItem; + } + + public FailedMediaSourceException getError() { + return error; + } + + private boolean canRetry() { + return System.currentTimeMillis() >= retryTimestamp; + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + throw new IOException(error); + } + + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, + final long startPositionUs) { + return null; + } + + @Override + public void releasePeriod(final MediaPeriod mediaPeriod) { } + + @Override + protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { + Log.e(TAG, "Loading failed source: ", error); + } + + @Override + protected void releaseSourceInternal() { } + + @Override + public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, + final boolean isInterruptable) { + return newIdentity != playQueueItem || canRetry(); + } + + @Override + public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { + return playQueueItem == stream; + } + + public static class FailedMediaSourceException extends Exception { + FailedMediaSourceException(final String message) { + super(message); + } + + FailedMediaSourceException(final Throwable cause) { + super(cause); + } + } + + public static final class MediaSourceResolutionException extends FailedMediaSourceException { + public MediaSourceResolutionException(final String message) { + super(message); + } + } + + public static final class StreamInfoLoadException extends FailedMediaSourceException { + public StreamInfoLoadException(final Throwable cause) { + super(cause); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/LoadedMediaSource.java new file mode 100644 index 000000000..db82d87b3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/LoadedMediaSource.java @@ -0,0 +1,96 @@ +package org.schabi.newpipelegacy.player.mediasource; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +import java.io.IOException; + +public class LoadedMediaSource implements ManagedMediaSource { + private final MediaSource source; + private final PlayQueueItem stream; + private final long expireTimestamp; + + public LoadedMediaSource(@NonNull final MediaSource source, @NonNull final PlayQueueItem stream, + final long expireTimestamp) { + this.source = source; + this.stream = stream; + this.expireTimestamp = expireTimestamp; + } + + public PlayQueueItem getStream() { + return stream; + } + + private boolean isExpired() { + return System.currentTimeMillis() >= expireTimestamp; + } + + @Override + public void prepareSource(final MediaSourceCaller mediaSourceCaller, + @Nullable final TransferListener mediaTransferListener) { + source.prepareSource(mediaSourceCaller, mediaTransferListener); + } + + @Override + public void maybeThrowSourceInfoRefreshError() throws IOException { + source.maybeThrowSourceInfoRefreshError(); + } + + @Override + public void enable(final MediaSourceCaller caller) { + source.enable(caller); + } + + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, + final long startPositionUs) { + return source.createPeriod(id, allocator, startPositionUs); + } + + @Override + public void releasePeriod(final MediaPeriod mediaPeriod) { + source.releasePeriod(mediaPeriod); + } + + @Override + public void disable(final MediaSourceCaller caller) { + source.disable(caller); + } + + @Override + public void releaseSource(final MediaSourceCaller mediaSourceCaller) { + source.releaseSource(mediaSourceCaller); + } + + @Override + public void addEventListener(final Handler handler, + final MediaSourceEventListener eventListener) { + source.addEventListener(handler, eventListener); + } + + @Override + public void removeEventListener(final MediaSourceEventListener eventListener) { + source.removeEventListener(eventListener); + } + + @Override + public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, + final boolean isInterruptable) { + return newIdentity != stream || (isInterruptable && isExpired()); + } + + @Override + public boolean isStreamEqual(@NonNull final PlayQueueItem otherStream) { + return this.stream == otherStream; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSource.java new file mode 100644 index 000000000..5de534ab5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSource.java @@ -0,0 +1,37 @@ +package org.schabi.newpipelegacy.player.mediasource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +public interface ManagedMediaSource extends MediaSource { + /** + * Determines whether or not this {@link ManagedMediaSource} can be replaced. + * + * @param newIdentity a stream the {@link ManagedMediaSource} should encapsulate over, if + * it is different from the existing stream in the + * {@link ManagedMediaSource}, then it should be replaced. + * @param isInterruptable specifies if this {@link ManagedMediaSource} potentially + * being played. + * @return whether this could be replaces + */ + boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, boolean isInterruptable); + + /** + * Determines if the {@link PlayQueueItem} is the one the + * {@link ManagedMediaSource} encapsulates over. + * + * @param stream play queue item to check + * @return whether this source is for the specified stream + */ + boolean isStreamEqual(@NonNull PlayQueueItem stream); + + @Nullable + @Override + default Object getTag() { + return this; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSourcePlaylist.java new file mode 100644 index 000000000..10933e7bd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/ManagedMediaSourcePlaylist.java @@ -0,0 +1,170 @@ +package org.schabi.newpipelegacy.player.mediasource; + +import android.os.Handler; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.ShuffleOrder; + +public class ManagedMediaSourcePlaylist { + @NonNull + private final ConcatenatingMediaSource internalSource; + + public ManagedMediaSourcePlaylist() { + internalSource = new ConcatenatingMediaSource(/*isPlaylistAtomic=*/false, + new ShuffleOrder.UnshuffledShuffleOrder(0)); + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Delegations + //////////////////////////////////////////////////////////////////////////*/ + + public int size() { + return internalSource.getSize(); + } + + /** + * Returns the {@link ManagedMediaSource} at the given index of the playlist. + * If the index is invalid, then null is returned. + * + * @param index index of {@link ManagedMediaSource} to get from the playlist + * @return the {@link ManagedMediaSource} at the given index of the playlist + */ + @Nullable + public ManagedMediaSource get(final int index) { + return (index < 0 || index >= size()) + ? null : (ManagedMediaSource) internalSource.getMediaSource(index).getTag(); + } + + @NonNull + public ConcatenatingMediaSource getParentMediaSource() { + return internalSource; + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Manipulation + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Expands the {@link ConcatenatingMediaSource} by appending it with a + * {@link PlaceholderMediaSource}. + * + * @see #append(ManagedMediaSource) + */ + public synchronized void expand() { + append(new PlaceholderMediaSource()); + } + + /** + * Appends a {@link ManagedMediaSource} to the end of {@link ConcatenatingMediaSource}. + * + * @see ConcatenatingMediaSource#addMediaSource + * @param source {@link ManagedMediaSource} to append + */ + public synchronized void append(@NonNull final ManagedMediaSource source) { + internalSource.addMediaSource(source); + } + + /** + * Removes a {@link ManagedMediaSource} from {@link ConcatenatingMediaSource} + * at the given index. If this index is out of bound, then the removal is ignored. + * + * @see ConcatenatingMediaSource#removeMediaSource(int) + * @param index of {@link ManagedMediaSource} to be removed + */ + public synchronized void remove(final int index) { + if (index < 0 || index > internalSource.getSize()) { + return; + } + + internalSource.removeMediaSource(index); + } + + /** + * Moves a {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} + * from the given source index to the target index. If either index is out of bound, + * then the call is ignored. + * + * @see ConcatenatingMediaSource#moveMediaSource(int, int) + * @param source original index of {@link ManagedMediaSource} + * @param target new index of {@link ManagedMediaSource} + */ + public synchronized void move(final int source, final int target) { + if (source < 0 || target < 0) { + return; + } + if (source >= internalSource.getSize() || target >= internalSource.getSize()) { + return; + } + + internalSource.moveMediaSource(source, target); + } + + /** + * Invalidates the {@link ManagedMediaSource} at the given index by replacing it + * with a {@link PlaceholderMediaSource}. + * + * @see #update(int, ManagedMediaSource, Handler, Runnable) + * @param index index of {@link ManagedMediaSource} to invalidate + * @param handler the {@link Handler} to run {@code finalizingAction} + * @param finalizingAction a {@link Runnable} which is executed immediately + * after the media source has been removed from the playlist + */ + public synchronized void invalidate(final int index, + @Nullable final Handler handler, + @Nullable final Runnable finalizingAction) { + if (get(index) instanceof PlaceholderMediaSource) { + return; + } + update(index, new PlaceholderMediaSource(), handler, finalizingAction); + } + + /** + * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} + * at the given index with a given {@link ManagedMediaSource}. + * + * @see #update(int, ManagedMediaSource, Handler, Runnable) + * @param index index of {@link ManagedMediaSource} to update + * @param source new {@link ManagedMediaSource} to use + */ + public synchronized void update(final int index, @NonNull final ManagedMediaSource source) { + update(index, source, null, /*doNothing=*/null); + } + + /** + * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} + * at the given index with a given {@link ManagedMediaSource}. If the index is out of bound, + * then the replacement is ignored. + * + * @see ConcatenatingMediaSource#addMediaSource + * @see ConcatenatingMediaSource#removeMediaSource(int, Handler, Runnable) + * @param index index of {@link ManagedMediaSource} to update + * @param source new {@link ManagedMediaSource} to use + * @param handler the {@link Handler} to run {@code finalizingAction} + * @param finalizingAction a {@link Runnable} which is executed immediately + * after the media source has been removed from the playlist + */ + public synchronized void update(final int index, @NonNull final ManagedMediaSource source, + @Nullable final Handler handler, + @Nullable final Runnable finalizingAction) { + if (index < 0 || index >= internalSource.getSize()) { + return; + } + + // Add and remove are sequential on the same thread, therefore here, the exoplayer + // message queue must receive and process add before remove, effectively treating them + // as atomic. + + // Since the finalizing action occurs strictly after the timeline has completed + // all its changes on the playback thread, thus, it is possible, in the meantime, + // other calls that modifies the playlist media source occur in between. This makes + // it unsafe to call remove as the finalizing action of add. + internalSource.addMediaSource(index + 1, source); + + // Because of the above race condition, it is thus only safe to synchronize the player + // in the finalizing action AFTER the removal is complete and the timeline has changed. + internalSource.removeMediaSource(index, handler, finalizingAction); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/PlaceholderMediaSource.java new file mode 100644 index 000000000..babd6ef75 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/mediasource/PlaceholderMediaSource.java @@ -0,0 +1,43 @@ +package org.schabi.newpipelegacy.player.mediasource; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.BaseMediaSource; +import com.google.android.exoplayer2.source.MediaPeriod; +import com.google.android.exoplayer2.upstream.Allocator; +import com.google.android.exoplayer2.upstream.TransferListener; + +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +public class PlaceholderMediaSource extends BaseMediaSource implements ManagedMediaSource { + // Do nothing, so this will stall the playback + @Override + public void maybeThrowSourceInfoRefreshError() { } + + @Override + public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, + final long startPositionUs) { + return null; + } + + @Override + public void releasePeriod(final MediaPeriod mediaPeriod) { } + + @Override + protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { } + + @Override + protected void releaseSourceInternal() { } + + @Override + public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, + final boolean isInterruptable) { + return true; + } + + @Override + public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playback/BasePlayerMediaSession.java b/app/src/main/java/org/schabi/newpipelegacy/player/playback/BasePlayerMediaSession.java new file mode 100644 index 000000000..76202d06d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playback/BasePlayerMediaSession.java @@ -0,0 +1,93 @@ +package org.schabi.newpipelegacy.player.playback; + +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.MediaMetadataCompat; + +import org.schabi.newpipelegacy.player.BasePlayer; +import org.schabi.newpipelegacy.player.mediasession.MediaSessionCallback; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +public class BasePlayerMediaSession implements MediaSessionCallback { + private final BasePlayer player; + + public BasePlayerMediaSession(final BasePlayer player) { + this.player = player; + } + + @Override + public void onSkipToPrevious() { + player.onPlayPrevious(); + } + + @Override + public void onSkipToNext() { + player.onPlayNext(); + } + + @Override + public void onSkipToIndex(final int index) { + if (player.getPlayQueue() == null) { + return; + } + player.onSelected(player.getPlayQueue().getItem(index)); + } + + @Override + public int getCurrentPlayingIndex() { + if (player.getPlayQueue() == null) { + return -1; + } + return player.getPlayQueue().getIndex(); + } + + @Override + public int getQueueSize() { + if (player.getPlayQueue() == null) { + return -1; + } + return player.getPlayQueue().size(); + } + + @Override + public MediaDescriptionCompat getQueueMetadata(final int index) { + if (player.getPlayQueue() == null || player.getPlayQueue().getItem(index) == null) { + return null; + } + + final PlayQueueItem item = player.getPlayQueue().getItem(index); + MediaDescriptionCompat.Builder descriptionBuilder = new MediaDescriptionCompat.Builder() + .setMediaId(String.valueOf(index)) + .setTitle(item.getTitle()) + .setSubtitle(item.getUploader()); + + // set additional metadata for A2DP/AVRCP + Bundle additionalMetadata = new Bundle(); + additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); + additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); + additionalMetadata + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); + additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1); + additionalMetadata + .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); + descriptionBuilder.setExtras(additionalMetadata); + + final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl()); + if (thumbnailUri != null) { + descriptionBuilder.setIconUri(thumbnailUri); + } + + return descriptionBuilder.build(); + } + + @Override + public void onPlay() { + player.onPlay(); + } + + @Override + public void onPause() { + player.onPause(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playback/CustomTrackSelector.java b/app/src/main/java/org/schabi/newpipelegacy/player/playback/CustomTrackSelector.java new file mode 100644 index 000000000..bb06b4291 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playback/CustomTrackSelector.java @@ -0,0 +1,92 @@ +package org.schabi.newpipelegacy.player.playback; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Pair; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.RendererCapabilities.Capabilities; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.util.Assertions; + +/** + * This class allows irregular text language labels for use when selecting text captions and + * is mostly a copy-paste from {@link DefaultTrackSelector}. + *

+ * This is a hack and should be removed once ExoPlayer fixes language normalization to accept + * a broader set of languages. + *

+ */ +public class CustomTrackSelector extends DefaultTrackSelector { + private String preferredTextLanguage; + + public CustomTrackSelector(final Context context, + final TrackSelection.Factory adaptiveTrackSelectionFactory) { + super(context, adaptiveTrackSelectionFactory); + } + + private static boolean formatHasLanguage(final Format format, final String language) { + return language != null && TextUtils.equals(language, format.language); + } + + public String getPreferredTextLanguage() { + return preferredTextLanguage; + } + + public void setPreferredTextLanguage(@NonNull final String label) { + Assertions.checkNotNull(label); + if (!label.equals(preferredTextLanguage)) { + preferredTextLanguage = label; + invalidate(); + } + } + + @Override + @Nullable + protected Pair selectTextTrack( + final TrackGroupArray groups, + @NonNull final int[][] formatSupport, + @NonNull final Parameters params, + @Nullable final String selectedAudioLanguage) { + TrackGroup selectedGroup = null; + int selectedTrackIndex = C.INDEX_UNSET; + TextTrackScore selectedTrackScore = null; + + for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) { + TrackGroup trackGroup = groups.get(groupIndex); + @Capabilities int[] trackFormatSupport = formatSupport[groupIndex]; + + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + if (isSupported(trackFormatSupport[trackIndex], + params.exceedRendererCapabilitiesIfNecessary)) { + Format format = trackGroup.getFormat(trackIndex); + TextTrackScore trackScore = new TextTrackScore(format, params, + trackFormatSupport[trackIndex], selectedAudioLanguage); + + if (formatHasLanguage(format, preferredTextLanguage)) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + break; // found user selected match (perfect!) + + } else if (trackScore.isWithinConstraints && (selectedTrackScore == null + || trackScore.compareTo(selectedTrackScore) > 0)) { + selectedGroup = trackGroup; + selectedTrackIndex = trackIndex; + selectedTrackScore = trackScore; + } + } + } + } + return selectedGroup == null ? null + : Pair.create(new TrackSelection.Definition(selectedGroup, selectedTrackIndex), + Assertions.checkNotNull(selectedTrackScore)); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipelegacy/player/playback/MediaSourceManager.java new file mode 100644 index 000000000..f587c8ee7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playback/MediaSourceManager.java @@ -0,0 +1,586 @@ +package org.schabi.newpipelegacy.player.playback; + +import android.os.Handler; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArraySet; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.player.mediasource.FailedMediaSource; +import org.schabi.newpipelegacy.player.mediasource.LoadedMediaSource; +import org.schabi.newpipelegacy.player.mediasource.ManagedMediaSource; +import org.schabi.newpipelegacy.player.mediasource.ManagedMediaSourcePlaylist; +import org.schabi.newpipelegacy.player.mediasource.PlaceholderMediaSource; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; +import org.schabi.newpipelegacy.player.playqueue.events.MoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.PlayQueueEvent; +import org.schabi.newpipelegacy.player.playqueue.events.RemoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.ReorderEvent; +import org.schabi.newpipelegacy.util.ServiceHelper; + +import java.util.Collection; +import java.util.Collections; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +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.internal.subscriptions.EmptySubscription; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.PublishSubject; + +import static org.schabi.newpipelegacy.player.mediasource.FailedMediaSource.MediaSourceResolutionException; +import static org.schabi.newpipelegacy.player.mediasource.FailedMediaSource.StreamInfoLoadException; +import static org.schabi.newpipelegacy.player.playqueue.PlayQueue.DEBUG; + +public class MediaSourceManager { + @NonNull + private final String TAG = "MediaSourceManager@" + hashCode(); + + /** + * Determines how many streams before and after the current stream should be loaded. + * The default value (1) ensures seamless playback under typical network settings. + *

+ * The streams after the current will be loaded into the playlist timeline while the + * streams before will only be cached for future usage. + *

+ * + * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) + */ + private static final int WINDOW_SIZE = 1; + + /** + * Determines the maximum number of disposables allowed in the {@link #loaderReactor}. + * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the + * {@link #loaderReactor} in order to load a new set of items. + * + * @see #loadImmediate() + * @see #maybeLoadItem(PlayQueueItem) + */ + private static final int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; + + @NonNull + private final PlaybackListener playbackListener; + @NonNull + private final PlayQueue playQueue; + + /** + * Determines the gap time between the playback position and the playback duration which + * the {@link #getEdgeIntervalSignal()} begins to request loading. + * + * @see #progressUpdateIntervalMillis + */ + private final long playbackNearEndGapMillis; + + /** + * Determines the interval which the {@link #getEdgeIntervalSignal()} waits for between + * each request for loading, once {@link #playbackNearEndGapMillis} has reached. + */ + private final long progressUpdateIntervalMillis; + + @NonNull + private final Observable nearEndIntervalSignal; + + /** + * Process only the last load order when receiving a stream of load orders (lessens I/O). + *

+ * The higher it is, the less loading occurs during rapid noncritical timeline changes. + *

+ *

+ * Not recommended to go below 100ms. + *

+ * + * @see #loadDebounced() + */ + private final long loadDebounceMillis; + + @NonNull + private final Disposable debouncedLoader; + @NonNull + private final PublishSubject debouncedSignal; + + @NonNull + private Subscription playQueueReactor; + + @NonNull + private final CompositeDisposable loaderReactor; + @NonNull + private final Set loadingItems; + + @NonNull + private final AtomicBoolean isBlocked; + + @NonNull + private ManagedMediaSourcePlaylist playlist; + + private Handler removeMediaSourceHandler = new Handler(); + + public MediaSourceManager(@NonNull final PlaybackListener listener, + @NonNull final PlayQueue playQueue) { + this(listener, playQueue, 400L, + /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), + /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); + } + + private MediaSourceManager(@NonNull final PlaybackListener listener, + @NonNull final PlayQueue playQueue, + final long loadDebounceMillis, + final long playbackNearEndGapMillis, + final long progressUpdateIntervalMillis) { + if (playQueue.getBroadcastReceiver() == null) { + throw new IllegalArgumentException("Play Queue has not been initialized."); + } + if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { + throw new IllegalArgumentException("Playback end gap=[" + playbackNearEndGapMillis + + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis + + " ms] for them to be useful."); + } + + this.playbackListener = listener; + this.playQueue = playQueue; + + this.playbackNearEndGapMillis = playbackNearEndGapMillis; + this.progressUpdateIntervalMillis = progressUpdateIntervalMillis; + this.nearEndIntervalSignal = getEdgeIntervalSignal(); + + this.loadDebounceMillis = loadDebounceMillis; + this.debouncedSignal = PublishSubject.create(); + this.debouncedLoader = getDebouncedLoader(); + + this.playQueueReactor = EmptySubscription.INSTANCE; + this.loaderReactor = new CompositeDisposable(); + + this.isBlocked = new AtomicBoolean(false); + + this.playlist = new ManagedMediaSourcePlaylist(); + + this.loadingItems = Collections.synchronizedSet(new ArraySet<>()); + + playQueue.getBroadcastReceiver() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getReactor()); + } + + /*////////////////////////////////////////////////////////////////////////// + // Exposed Methods + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Dispose the manager and releases all message buses and loaders. + */ + public void dispose() { + if (DEBUG) { + Log.d(TAG, "close() called."); + } + + debouncedSignal.onComplete(); + debouncedLoader.dispose(); + + playQueueReactor.cancel(); + loaderReactor.dispose(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Event Reactor + //////////////////////////////////////////////////////////////////////////*/ + + private Subscriber getReactor() { + return new Subscriber() { + @Override + public void onSubscribe(@NonNull final Subscription d) { + playQueueReactor.cancel(); + playQueueReactor = d; + playQueueReactor.request(1); + } + + @Override + public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { + onPlayQueueChanged(playQueueMessage); + } + + @Override + public void onError(@NonNull final Throwable e) { } + + @Override + public void onComplete() { } + }; + } + + private void onPlayQueueChanged(final PlayQueueEvent event) { + if (playQueue.isEmpty() && playQueue.isComplete()) { + playbackListener.onPlaybackShutdown(); + return; + } + + // Event specific action + switch (event.type()) { + case INIT: + case ERROR: + maybeBlock(); + case APPEND: + populateSources(); + break; + case SELECT: + maybeRenewCurrentIndex(); + break; + case REMOVE: + final RemoveEvent removeEvent = (RemoveEvent) event; + playlist.remove(removeEvent.getRemoveIndex()); + break; + case MOVE: + final MoveEvent moveEvent = (MoveEvent) event; + playlist.move(moveEvent.getFromIndex(), moveEvent.getToIndex()); + break; + case REORDER: + // Need to move to ensure the playing index from play queue matches that of + // the source timeline, and then window correction can take care of the rest + final ReorderEvent reorderEvent = (ReorderEvent) event; + playlist.move(reorderEvent.getFromSelectedIndex(), + reorderEvent.getToSelectedIndex()); + break; + case RECOVERY: + default: + break; + } + + // Loading and Syncing + switch (event.type()) { + case INIT: + case REORDER: + case ERROR: + case SELECT: + loadImmediate(); // low frequency, critical events + break; + case APPEND: + case REMOVE: + case MOVE: + case RECOVERY: + default: + loadDebounced(); // high frequency or noncritical events + break; + } + + if (!isPlayQueueReady()) { + maybeBlock(); + playQueue.fetch(); + } + playQueueReactor.request(1); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playback Locking + //////////////////////////////////////////////////////////////////////////*/ + + private boolean isPlayQueueReady() { + final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; + return playQueue.isComplete() || isWindowLoaded; + } + + private boolean isPlaybackReady() { + if (playlist.size() != playQueue.size()) { + return false; + } + + final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); + if (mediaSource == null) { + return false; + } + + final PlayQueueItem playQueueItem = playQueue.getItem(); + return mediaSource.isStreamEqual(playQueueItem); + } + + private void maybeBlock() { + if (DEBUG) { + Log.d(TAG, "maybeBlock() called."); + } + + if (isBlocked.get()) { + return; + } + + playbackListener.onPlaybackBlock(); + resetSources(); + + isBlocked.set(true); + } + + private void maybeUnblock() { + if (DEBUG) { + Log.d(TAG, "maybeUnblock() called."); + } + + if (isBlocked.get()) { + isBlocked.set(false); + playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization + //////////////////////////////////////////////////////////////////////////*/ + + private void maybeSync() { + if (DEBUG) { + Log.d(TAG, "maybeSync() called."); + } + + final PlayQueueItem currentItem = playQueue.getItem(); + if (isBlocked.get() || currentItem == null) { + return; + } + + playbackListener.onPlaybackSynchronize(currentItem); + } + + private synchronized void maybeSynchronizePlayer() { + if (isPlayQueueReady() && isPlaybackReady()) { + maybeUnblock(); + maybeSync(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Loading + //////////////////////////////////////////////////////////////////////////*/ + + private Observable getEdgeIntervalSignal() { + return Observable.interval(progressUpdateIntervalMillis, + TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .filter(ignored -> + playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis)); + } + + private Disposable getDebouncedLoader() { + return debouncedSignal.mergeWith(nearEndIntervalSignal) + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.single()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(timestamp -> loadImmediate()); + } + + private void loadDebounced() { + debouncedSignal.onNext(System.currentTimeMillis()); + } + + private void loadImmediate() { + if (DEBUG) { + Log.d(TAG, "MediaSource - loadImmediate() called"); + } + final ItemsToLoad itemsToLoad = getItemsToLoad(playQueue); + if (itemsToLoad == null) { + return; + } + + // Evict the previous items being loaded to free up memory, before start loading new ones + maybeClearLoaders(); + + maybeLoadItem(itemsToLoad.center); + for (final PlayQueueItem item : itemsToLoad.neighbors) { + maybeLoadItem(item); + } + } + + private void maybeLoadItem(@NonNull final PlayQueueItem item) { + if (DEBUG) { + Log.d(TAG, "maybeLoadItem() called."); + } + if (playQueue.indexOf(item) >= playlist.size()) { + return; + } + + if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { + if (DEBUG) { + Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + "] " + + "with url=[" + item.getUrl() + "]"); + } + + loadingItems.add(item); + final Disposable loader = getLoadedMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) + /* No exception handling since getLoadedMediaSource guarantees nonnull return */ + .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); + loaderReactor.add(loader); + } + } + + private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { + return stream.getStream().map(streamInfo -> { + final MediaSource source = playbackListener.sourceOf(stream, streamInfo); + if (source == null) { + final String message = "Unable to resolve source from stream info. " + + "URL: " + stream.getUrl() + ", " + + "audio count: " + streamInfo.getAudioStreams().size() + ", " + + "video count: " + streamInfo.getVideoOnlyStreams().size() + ", " + + streamInfo.getVideoStreams().size(); + return new FailedMediaSource(stream, new MediaSourceResolutionException(message)); + } + + final long expiration = System.currentTimeMillis() + + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); + return new LoadedMediaSource(source, stream, expiration); + }).onErrorReturn(throwable -> new FailedMediaSource(stream, + new StreamInfoLoadException(throwable))); + } + + private void onMediaSourceReceived(@NonNull final PlayQueueItem item, + @NonNull final ManagedMediaSource mediaSource) { + if (DEBUG) { + Log.d(TAG, "MediaSource - Loaded=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]"); + } + + loadingItems.remove(item); + + final int itemIndex = playQueue.indexOf(item); + // Only update the playlist timeline for items at the current index or after. + if (isCorrectionNeeded(item)) { + if (DEBUG) { + Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " + + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); + } + playlist.update(itemIndex, mediaSource, removeMediaSourceHandler, + this::maybeSynchronizePlayer); + } + } + + /** + * Checks if the corresponding MediaSource in + * {@link com.google.android.exoplayer2.source.ConcatenatingMediaSource} + * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback + * readiness or playlist desynchronization. + *

+ * If the given {@link PlayQueueItem} is currently being played and is already loaded, + * then correction is not only needed if the playlist is desynchronized. Otherwise, the + * check depends on the status (e.g. expiration or placeholder) of the + * {@link ManagedMediaSource}. + *

+ * + * @param item {@link PlayQueueItem} to check + * @return whether a correction is needed + */ + private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { + final int index = playQueue.indexOf(item); + final ManagedMediaSource mediaSource = playlist.get(index); + return mediaSource != null && mediaSource.shouldBeReplacedWith(item, + index != playQueue.getIndex()); + } + + /** + * Checks if the current playing index contains an expired {@link ManagedMediaSource}. + * If so, the expired source is replaced by a {@link PlaceholderMediaSource} and + * {@link #loadImmediate()} is called to reload the current item. + *

+ * If not, then the media source at the current index is ready for playback, and + * {@link #maybeSynchronizePlayer()} is called. + *

+ * Under both cases, {@link #maybeSync()} will be called to ensure the listener + * is up-to-date. + */ + private void maybeRenewCurrentIndex() { + final int currentIndex = playQueue.getIndex(); + final ManagedMediaSource currentSource = playlist.get(currentIndex); + if (currentSource == null) { + return; + } + + final PlayQueueItem currentItem = playQueue.getItem(); + if (!currentSource.shouldBeReplacedWith(currentItem, true)) { + maybeSynchronizePlayer(); + return; + } + + if (DEBUG) { + Log.d(TAG, "MediaSource - Reloading currently playing, " + + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); + } + playlist.invalidate(currentIndex, removeMediaSourceHandler, this::loadImmediate); + } + + private void maybeClearLoaders() { + if (DEBUG) { + Log.d(TAG, "MediaSource - maybeClearLoaders() called."); + } + if (!loadingItems.contains(playQueue.getItem()) + && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { + loaderReactor.clear(); + loadingItems.clear(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Playlist Helpers + //////////////////////////////////////////////////////////////////////////*/ + + private void resetSources() { + if (DEBUG) { + Log.d(TAG, "resetSources() called."); + } + playlist = new ManagedMediaSourcePlaylist(); + } + + private void populateSources() { + if (DEBUG) { + Log.d(TAG, "populateSources() called."); + } + while (playlist.size() < playQueue.size()) { + playlist.expand(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Manager Helpers + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + private static ItemsToLoad getItemsToLoad(@NonNull final PlayQueue playQueue) { + // The current item has higher priority + final int currentIndex = playQueue.getIndex(); + final PlayQueueItem currentItem = playQueue.getItem(currentIndex); + if (currentItem == null) { + return null; + } + + // The rest are just for seamless playback + // Although timeline is not updated prior to the current index, these sources are still + // loaded into the cache for faster retrieval at a potentially later time. + final int leftBound = Math.max(0, currentIndex - MediaSourceManager.WINDOW_SIZE); + final int rightLimit = currentIndex + MediaSourceManager.WINDOW_SIZE + 1; + final int rightBound = Math.min(playQueue.size(), rightLimit); + final Set neighbors = new ArraySet<>( + playQueue.getStreams().subList(leftBound, rightBound)); + + // Do a round robin + final int excess = rightLimit - playQueue.size(); + if (excess >= 0) { + neighbors.addAll(playQueue.getStreams() + .subList(0, Math.min(playQueue.size(), excess))); + } + neighbors.remove(currentItem); + + return new ItemsToLoad(currentItem, neighbors); + } + + private static class ItemsToLoad { + @NonNull + private final PlayQueueItem center; + @NonNull + private final Collection neighbors; + + ItemsToLoad(@NonNull final PlayQueueItem center, + @NonNull final Collection neighbors) { + this.center = center; + this.neighbors = neighbors; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipelegacy/player/playback/PlaybackListener.java new file mode 100644 index 000000000..f4913bb3f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playback/PlaybackListener.java @@ -0,0 +1,80 @@ +package org.schabi.newpipelegacy.player.playback; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.player.playqueue.PlayQueueItem; + +public interface PlaybackListener { + /** + * Called to check if the currently playing stream is approaching the end of its playback. + * Implementation should return true when the current playback position is progressing within + * timeToEndMillis or less to its playback during. + *

+ * May be called at any time. + *

+ * + * @param timeToEndMillis + * @return whether the stream is approaching end of playback + */ + boolean isApproachingPlaybackEdge(long timeToEndMillis); + + /** + * Called when the stream at the current queue index is not ready yet. + * Signals to the listener to block the player from playing anything and notify the source + * is now invalid. + *

+ * May be called at any time. + *

+ */ + void onPlaybackBlock(); + + /** + * Called when the stream at the current queue index is ready. + * Signals to the listener to resume the player by preparing a new source. + *

+ * May be called only when the player is blocked. + *

+ * + * @param mediaSource + */ + void onPlaybackUnblock(MediaSource mediaSource); + + /** + * Called when the queue index is refreshed. + * Signals to the listener to synchronize the player's window to the manager's + * window. + *

+ * May be called anytime at any amount once unblock is called. + *

+ * + * @param item + */ + void onPlaybackSynchronize(@NonNull PlayQueueItem item); + + /** + * Requests the listener to resolve a stream info into a media source + * according to the listener's implementation (background, popup or main video player). + *

+ * May be called at any time. + *

+ * @param item + * @param info + * @return the corresponding {@link MediaSource} + */ + @Nullable + MediaSource sourceOf(PlayQueueItem item, StreamInfo info); + + /** + * Called when the play queue can no longer to played or used. + * Currently, this means the play queue is empty and complete. + * Signals to the listener that it should shutdown. + *

+ * May be called at any time. + *

+ */ + void onPlaybackShutdown(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/AbstractInfoPlayQueue.java new file mode 100644 index 000000000..fb4c47c8c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/AbstractInfoPlayQueue.java @@ -0,0 +1,140 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.ListInfo; +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.SingleObserver; +import io.reactivex.disposables.Disposable; + +abstract class AbstractInfoPlayQueue extends PlayQueue { + boolean isInitial; + private boolean isComplete; + + final int serviceId; + final String baseUrl; + Page nextPage; + + private transient Disposable fetchReactor; + + AbstractInfoPlayQueue(final U item) { + this(item.getServiceId(), item.getUrl(), null, Collections.emptyList(), 0); + } + + AbstractInfoPlayQueue(final int serviceId, final String url, final Page nextPage, + final List streams, final int index) { + super(index, extractListItems(streams)); + + this.baseUrl = url; + this.nextPage = nextPage; + this.serviceId = serviceId; + + this.isInitial = streams.isEmpty(); + this.isComplete = !isInitial && !Page.isValid(nextPage); + } + + protected abstract String getTag(); + + @Override + public boolean isComplete() { + return isComplete; + } + + SingleObserver getHeadListObserver() { + return new SingleObserver() { + @Override + public void onSubscribe(@NonNull final Disposable d) { + if (isComplete || !isInitial || (fetchReactor != null + && !fetchReactor.isDisposed())) { + d.dispose(); + } else { + fetchReactor = d; + } + } + + @Override + public void onSuccess(@NonNull final T result) { + isInitial = false; + if (!result.hasNextPage()) { + isComplete = true; + } + nextPage = result.getNextPage(); + + append(extractListItems(result.getRelatedItems())); + + fetchReactor.dispose(); + fetchReactor = null; + } + + @Override + public void onError(@NonNull final Throwable e) { + Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); + isComplete = true; + append(); // Notify change + } + }; + } + + SingleObserver getNextPageObserver() { + return new SingleObserver() { + @Override + public void onSubscribe(@NonNull final Disposable d) { + if (isComplete || isInitial || (fetchReactor != null + && !fetchReactor.isDisposed())) { + d.dispose(); + } else { + fetchReactor = d; + } + } + + @Override + public void onSuccess(@NonNull final ListExtractor.InfoItemsPage result) { + if (!result.hasNextPage()) { + isComplete = true; + } + nextPage = result.getNextPage(); + + append(extractListItems(result.getItems())); + + fetchReactor.dispose(); + fetchReactor = null; + } + + @Override + public void onError(@NonNull final Throwable e) { + Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); + isComplete = true; + append(); // Notify change + } + }; + } + + @Override + public void dispose() { + super.dispose(); + if (fetchReactor != null) { + fetchReactor.dispose(); + } + fetchReactor = null; + } + + private static List extractListItems(final List infos) { + List result = new ArrayList<>(); + for (final InfoItem stream : infos) { + if (stream instanceof StreamInfoItem) { + result.add(new PlayQueueItem((StreamInfoItem) stream)); + } + } + return result; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/ChannelPlayQueue.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/ChannelPlayQueue.java new file mode 100644 index 000000000..40fce6a90 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/ChannelPlayQueue.java @@ -0,0 +1,51 @@ +package org.schabi.newpipelegacy.player.playqueue; + + +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.util.ExtractorHelper; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; + +public final class ChannelPlayQueue extends AbstractInfoPlayQueue { + public ChannelPlayQueue(final ChannelInfoItem item) { + super(item); + } + + public ChannelPlayQueue(final ChannelInfo info) { + this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + } + + public ChannelPlayQueue(final int serviceId, + final String url, + final Page nextPage, + final List streams, + final int index) { + super(serviceId, url, nextPage, streams, index); + } + + @Override + protected String getTag() { + return "ChannelPlayQueue@" + Integer.toHexString(hashCode()); + } + + @Override + public void fetch() { + if (this.isInitial) { + ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } else { + ExtractorHelper.getMoreChannelItems(this.serviceId, this.baseUrl, this.nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueue.java new file mode 100644 index 000000000..60db4ff09 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueue.java @@ -0,0 +1,505 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.player.playqueue.events.AppendEvent; +import org.schabi.newpipelegacy.player.playqueue.events.ErrorEvent; +import org.schabi.newpipelegacy.player.playqueue.events.InitEvent; +import org.schabi.newpipelegacy.player.playqueue.events.MoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.PlayQueueEvent; +import org.schabi.newpipelegacy.player.playqueue.events.RecoveryEvent; +import org.schabi.newpipelegacy.player.playqueue.events.RemoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.ReorderEvent; +import org.schabi.newpipelegacy.player.playqueue.events.SelectEvent; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import io.reactivex.BackpressureStrategy; +import io.reactivex.Flowable; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.subjects.BehaviorSubject; + +/** + * PlayQueue is responsible for keeping track of a list of streams and the index of + * the stream that should be currently playing. + *

+ * This class contains basic manipulation of a playlist while also functions as a + * message bus, providing all listeners with new updates to the play queue. + *

+ *

+ * This class can be serialized for passing intents, but in order to start the + * message bus, it must be initialized. + *

+ */ +public abstract class PlayQueue implements Serializable { + private final String TAG = "PlayQueue@" + Integer.toHexString(hashCode()); + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + + private ArrayList backup; + private ArrayList streams; + + @NonNull + private final AtomicInteger queueIndex; + + private transient BehaviorSubject eventBroadcast; + private transient Flowable broadcastReceiver; + private transient Subscription reportingReactor; + + PlayQueue(final int index, final List startWith) { + streams = new ArrayList<>(); + streams.addAll(startWith); + + queueIndex = new AtomicInteger(index); + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist actions + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Initializes the play queue message buses. + *

+ * Also starts a self reporter for logging if debug mode is enabled. + *

+ */ + public void init() { + eventBroadcast = BehaviorSubject.create(); + + broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) + .observeOn(AndroidSchedulers.mainThread()) + .startWith(new InitEvent()); + + if (DEBUG) { + broadcastReceiver.subscribe(getSelfReporter()); + } + } + + /** + * Dispose the play queue by stopping all message buses. + */ + public void dispose() { + if (eventBroadcast != null) { + eventBroadcast.onComplete(); + } + if (reportingReactor != null) { + reportingReactor.cancel(); + } + + eventBroadcast = null; + broadcastReceiver = null; + reportingReactor = null; + } + + /** + * Checks if the queue is complete. + *

+ * A queue is complete if it has loaded all items in an external playlist + * single stream or local queues are always complete. + *

+ * + * @return whether the queue is complete + */ + public abstract boolean isComplete(); + + /** + * Load partial queue in the background, does nothing if the queue is complete. + */ + public abstract void fetch(); + + /*////////////////////////////////////////////////////////////////////////// + // Readonly ops + //////////////////////////////////////////////////////////////////////////*/ + + /** + * @return the current index that should be played + */ + public int getIndex() { + return queueIndex.get(); + } + + /** + * Changes the current playing index to a new index. + *

+ * This method is guarded using in a circular manner for index exceeding the play queue size. + *

+ *

+ * Will emit a {@link SelectEvent} if the index is not the current playing index. + *

+ * + * @param index the index to be set + */ + public synchronized void setIndex(final int index) { + final int oldIndex = getIndex(); + + int newIndex = index; + if (index < 0) { + newIndex = 0; + } + if (index >= streams.size()) { + newIndex = isComplete() ? index % streams.size() : streams.size() - 1; + } + + queueIndex.set(newIndex); + broadcast(new SelectEvent(oldIndex, newIndex)); + } + + /** + * @return the current item that should be played + */ + public PlayQueueItem getItem() { + return getItem(getIndex()); + } + + /** + * @param index the index of the item to return + * @return the item at the given index + * @throws IndexOutOfBoundsException + */ + public PlayQueueItem getItem(final int index) { + if (index < 0 || index >= streams.size() || streams.get(index) == null) { + return null; + } + return streams.get(index); + } + + /** + * Returns the index of the given item using referential equality. + * May be null despite play queue contains identical item. + * + * @param item the item to find the index of + * @return the index of the given item + */ + public int indexOf(@NonNull final PlayQueueItem item) { + // referential equality, can't think of a better way to do this + // todo: better than this + return streams.indexOf(item); + } + + /** + * @return the current size of play queue. + */ + public int size() { + return streams.size(); + } + + /** + * Checks if the play queue is empty. + * + * @return whether the play queue is empty + */ + public boolean isEmpty() { + return streams.isEmpty(); + } + + /** + * Determines if the current play queue is shuffled. + * + * @return whether the play queue is shuffled + */ + public boolean isShuffled() { + return backup != null; + } + + /** + * @return an immutable view of the play queue + */ + @NonNull + public List getStreams() { + return Collections.unmodifiableList(streams); + } + + /*////////////////////////////////////////////////////////////////////////// + // Write ops + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Returns the play queue's update broadcast. + * May be null if the play queue message bus is not initialized. + * + * @return the play queue's update broadcast + */ + @Nullable + public Flowable getBroadcastReceiver() { + return broadcastReceiver; + } + + /** + * Changes the current playing index by an offset amount. + *

+ * Will emit a {@link SelectEvent} if offset is non-zero. + *

+ * + * @param offset the offset relative to the current index + */ + public synchronized void offsetIndex(final int offset) { + setIndex(getIndex() + offset); + } + + /** + * Appends the given {@link PlayQueueItem}s to the current play queue. + * + * @see #append(List items) + * @param items {@link PlayQueueItem}s to append + */ + public synchronized void append(@NonNull final PlayQueueItem... items) { + append(Arrays.asList(items)); + } + + /** + * Appends the given {@link PlayQueueItem}s to the current play queue. + *

+ * If the play queue is shuffled, then append the items to the backup queue as is and + * append the shuffle items to the play queue. + *

+ *

+ * Will emit a {@link AppendEvent} on any given context. + *

+ * + * @param items {@link PlayQueueItem}s to append + */ + public synchronized void append(@NonNull final List items) { + List itemList = new ArrayList<>(items); + + if (isShuffled()) { + 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())); + } + + /** + * Removes the item at the given index from the play queue. + *

+ * The current playing index will decrement if it is greater than the index being removed. + * On cases where the current playing index exceeds the playlist range, it is set to 0. + *

+ *

+ * Will emit a {@link RemoveEvent} if the index is within the play queue index range. + *

+ * + * @param index the index of the item to remove + */ + public synchronized void remove(final int index) { + if (index >= streams.size() || index < 0) { + return; + } + removeInternal(index); + broadcast(new RemoveEvent(index, getIndex())); + } + + /** + * Report an exception for the item at the current index in order and skip to the next one + *

+ * This is done as a separate event as the underlying manager may have + * different implementation regarding exceptions. + *

+ */ + public synchronized void error() { + final int oldIndex = getIndex(); + queueIndex.incrementAndGet(); + broadcast(new ErrorEvent(oldIndex, getIndex())); + } + + private synchronized void removeInternal(final int removeIndex) { + final int currentIndex = queueIndex.get(); + final int size = size(); + + if (currentIndex > removeIndex) { + queueIndex.decrementAndGet(); + + } else if (currentIndex >= size) { + queueIndex.set(currentIndex % (size - 1)); + + } else if (currentIndex == removeIndex && currentIndex == size - 1) { + queueIndex.set(0); + } + + if (backup != null) { + backup.remove(getItem(removeIndex)); + } + streams.remove(removeIndex); + } + + /** + * Moves a queue item at the source index to the target index. + *

+ * If the item being moved is the currently playing, then the current playing index is set + * to that of the target. + * If the moved item is not the currently playing and moves to an index AFTER the + * current playing index, then the current playing index is decremented. + * Vice versa if the an item after the currently playing is moved BEFORE. + *

+ * + * @param source the original index of the item + * @param target the new index of the item + */ + public synchronized void move(final int source, final int target) { + if (source < 0 || target < 0) { + return; + } + if (source >= streams.size() || target >= streams.size()) { + return; + } + + final int current = getIndex(); + if (source == current) { + queueIndex.set(target); + } else if (source < current && target >= current) { + queueIndex.decrementAndGet(); + } else if (source > current && target <= current) { + queueIndex.incrementAndGet(); + } + + PlayQueueItem playQueueItem = streams.remove(source); + playQueueItem.setAutoQueued(false); + streams.add(target, playQueueItem); + broadcast(new MoveEvent(source, target)); + } + + /** + * Sets the recovery record of the item at the index. + *

+ * Broadcasts a recovery event. + *

+ * + * @param index index of the item + * @param position the recovery position + */ + public synchronized void setRecovery(final int index, final long position) { + if (index < 0 || index >= streams.size()) { + return; + } + + streams.get(index).setRecoveryPosition(position); + broadcast(new RecoveryEvent(index, position)); + } + + /** + * Revoke the recovery record of the item at the index. + *

+ * Broadcasts a recovery event. + *

+ * + * @param index index of the item + */ + public synchronized void unsetRecovery(final int index) { + setRecovery(index, PlayQueueItem.RECOVERY_UNSET); + } + + /** + * Shuffles the current play queue. + *

+ * This method first backs up the existing play queue and item being played. + * Then a newly shuffled play queue will be generated along with currently + * playing item placed at the beginning of the queue. + *

+ *

+ * Will emit a {@link ReorderEvent} in any context. + *

+ */ + public synchronized void shuffle() { + if (backup == null) { + backup = new ArrayList<>(streams); + } + final int originIndex = getIndex(); + final PlayQueueItem current = getItem(); + Collections.shuffle(streams); + + final int newIndex = streams.indexOf(current); + if (newIndex != -1) { + streams.add(0, streams.remove(newIndex)); + } + queueIndex.set(0); + + broadcast(new ReorderEvent(originIndex, queueIndex.get())); + } + + /** + * Unshuffles the current play queue if a backup play queue exists. + *

+ * This method undoes shuffling and index will be set to the previously playing item if found, + * otherwise, the index will reset to 0. + *

+ *

+ * Will emit a {@link ReorderEvent} if a backup exists. + *

+ */ + public synchronized void unshuffle() { + if (backup == null) { + return; + } + final int originIndex = getIndex(); + final PlayQueueItem current = getItem(); + + streams.clear(); + streams = backup; + backup = null; + + final int newIndex = streams.indexOf(current); + if (newIndex != -1) { + queueIndex.set(newIndex); + } else { + queueIndex.set(0); + } + + broadcast(new ReorderEvent(originIndex, queueIndex.get())); + } + + /*////////////////////////////////////////////////////////////////////////// + // Rx Broadcast + //////////////////////////////////////////////////////////////////////////*/ + + private void broadcast(@NonNull final PlayQueueEvent event) { + if (eventBroadcast != null) { + eventBroadcast.onNext(event); + } + } + + private Subscriber getSelfReporter() { + return new Subscriber() { + @Override + public void onSubscribe(final Subscription s) { + if (reportingReactor != null) { + reportingReactor.cancel(); + } + reportingReactor = s; + reportingReactor.request(1); + } + + @Override + public void onNext(final PlayQueueEvent event) { + Log.d(TAG, "Received broadcast: " + event.type().name() + ". " + + "Current index: " + getIndex() + ", play queue length: " + size() + "."); + reportingReactor.request(1); + } + + @Override + public void onError(final Throwable t) { + Log.e(TAG, "Received broadcast error", t); + } + + @Override + public void onComplete() { + Log.d(TAG, "Broadcast is shutting down."); + } + }; + } +} + diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueAdapter.java new file mode 100644 index 000000000..ec102bf0d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueAdapter.java @@ -0,0 +1,226 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.player.playqueue.events.AppendEvent; +import org.schabi.newpipelegacy.player.playqueue.events.ErrorEvent; +import org.schabi.newpipelegacy.player.playqueue.events.MoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.PlayQueueEvent; +import org.schabi.newpipelegacy.player.playqueue.events.RemoveEvent; +import org.schabi.newpipelegacy.player.playqueue.events.SelectEvent; +import org.schabi.newpipelegacy.util.FallbackViewHolder; + +import java.util.List; + +import io.reactivex.Observer; +import io.reactivex.disposables.Disposable; + +/** + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * InfoListAdapter.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 PlayQueueAdapter extends RecyclerView.Adapter { + private static final String TAG = PlayQueueAdapter.class.toString(); + + private static final int ITEM_VIEW_TYPE_ID = 0; + private static final int FOOTER_VIEW_TYPE_ID = 1; + + private final PlayQueueItemBuilder playQueueItemBuilder; + private final PlayQueue playQueue; + private boolean showFooter = false; + private View footer = null; + + private Disposable playQueueReactor; + + public PlayQueueAdapter(final Context context, final PlayQueue playQueue) { + if (playQueue.getBroadcastReceiver() == null) { + throw new IllegalStateException("Play Queue has not been initialized."); + } + + this.playQueueItemBuilder = new PlayQueueItemBuilder(context); + this.playQueue = playQueue; + + playQueue.getBroadcastReceiver().toObservable().subscribe(getReactor()); + } + + private Observer getReactor() { + return new Observer() { + @Override + public void onSubscribe(@NonNull final Disposable d) { + if (playQueueReactor != null) { + playQueueReactor.dispose(); + } + playQueueReactor = d; + } + + @Override + public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { + if (playQueueReactor != null) { + onPlayQueueChanged(playQueueMessage); + } + } + + @Override + public void onError(@NonNull final Throwable e) { } + + @Override + public void onComplete() { + dispose(); + } + }; + + } + + private void onPlayQueueChanged(final PlayQueueEvent message) { + switch (message.type()) { + case RECOVERY: + // Do nothing. + break; + case SELECT: + final SelectEvent selectEvent = (SelectEvent) message; + notifyItemChanged(selectEvent.getOldIndex()); + notifyItemChanged(selectEvent.getNewIndex()); + break; + case APPEND: + final AppendEvent appendEvent = (AppendEvent) message; + notifyItemRangeInserted(playQueue.size(), appendEvent.getAmount()); + break; + case ERROR: + final ErrorEvent errorEvent = (ErrorEvent) message; + notifyItemChanged(errorEvent.getErrorIndex()); + notifyItemChanged(errorEvent.getQueueIndex()); + break; + case REMOVE: + final RemoveEvent removeEvent = (RemoveEvent) message; + notifyItemRemoved(removeEvent.getRemoveIndex()); + notifyItemChanged(removeEvent.getQueueIndex()); + break; + case MOVE: + final MoveEvent moveEvent = (MoveEvent) message; + notifyItemMoved(moveEvent.getFromIndex(), moveEvent.getToIndex()); + break; + case INIT: + case REORDER: + default: + notifyDataSetChanged(); + break; + } + } + + public void dispose() { + if (playQueueReactor != null) { + playQueueReactor.dispose(); + } + playQueueReactor = null; + } + + public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) { + playQueueItemBuilder.setOnSelectedListener(listener); + } + + public void unsetSelectedListener() { + playQueueItemBuilder.setOnSelectedListener(null); + } + + public void setFooter(final View footer) { + this.footer = footer; + notifyItemChanged(playQueue.size()); + } + + public void showFooter(final boolean show) { + showFooter = show; + notifyItemChanged(playQueue.size()); + } + + public List getItems() { + return playQueue.getStreams(); + } + + @Override + public int getItemCount() { + int count = playQueue.getStreams().size(); + if (footer != null && showFooter) { + count++; + } + return count; + } + + @Override + public int getItemViewType(final int position) { + if (footer != null && position == playQueue.getStreams().size() && showFooter) { + return FOOTER_VIEW_TYPE_ID; + } + + return ITEM_VIEW_TYPE_ID; + } + + @Override + public RecyclerView.ViewHolder onCreateViewHolder(final ViewGroup parent, final int type) { + switch (type) { + case FOOTER_VIEW_TYPE_ID: + return new HFHolder(footer); + case ITEM_VIEW_TYPE_ID: + return new PlayQueueItemHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.play_queue_item, parent, false)); + default: + Log.e(TAG, "Attempting to create view holder with undefined type: " + type); + return new FallbackViewHolder(new View(parent.getContext())); + } + } + + @Override + public void onBindViewHolder(final RecyclerView.ViewHolder holder, final int position) { + if (holder instanceof PlayQueueItemHolder) { + final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; + + // Build the list item + playQueueItemBuilder + .buildStreamInfoItem(itemHolder, playQueue.getStreams().get(position)); + + // Check if the current item should be selected/highlighted + final boolean isSelected = playQueue.getIndex() == position; + itemHolder.itemSelected.setVisibility(isSelected ? View.VISIBLE : View.INVISIBLE); + itemHolder.itemView.setSelected(isSelected); + } else if (holder instanceof HFHolder && position == playQueue.getStreams().size() + && footer != null && showFooter) { + ((HFHolder) holder).view = footer; + } + } + + public class HFHolder extends RecyclerView.ViewHolder { + public View view; + + public HFHolder(final View v) { + super(v); + view = v; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItem.java new file mode 100644 index 000000000..74808d03b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItem.java @@ -0,0 +1,131 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.util.ExtractorHelper; + +import java.io.Serializable; + +import io.reactivex.Single; +import io.reactivex.schedulers.Schedulers; + +public class PlayQueueItem implements Serializable { + public static final long RECOVERY_UNSET = Long.MIN_VALUE; + private static final String EMPTY_STRING = ""; + + @NonNull + private final String title; + @NonNull + private final String url; + private final int serviceId; + private final long duration; + @NonNull + private final String thumbnailUrl; + @NonNull + private final String uploader; + @NonNull + private final 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()); + + if (info.getStartPosition() > 0) { + setRecoveryPosition(info.getStartPosition() * 1000); + } + } + + PlayQueueItem(@NonNull final StreamInfoItem item) { + this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), + item.getThumbnailUrl(), item.getUploaderName(), item.getStreamType()); + } + + private PlayQueueItem(@Nullable final String name, @Nullable final String url, + final int serviceId, final long duration, + @Nullable final String thumbnailUrl, @Nullable final String uploader, + @NonNull final StreamType streamType) { + this.title = name != null ? name : EMPTY_STRING; + this.url = url != null ? url : EMPTY_STRING; + this.serviceId = serviceId; + this.duration = duration; + this.thumbnailUrl = thumbnailUrl != null ? thumbnailUrl : EMPTY_STRING; + this.uploader = uploader != null ? uploader : EMPTY_STRING; + this.streamType = streamType; + + this.recoveryPosition = RECOVERY_UNSET; + } + + @NonNull + public String getTitle() { + return title; + } + + @NonNull + public String getUrl() { + return url; + } + + public int getServiceId() { + return serviceId; + } + + public long getDuration() { + return duration; + } + + @NonNull + public String getThumbnailUrl() { + return thumbnailUrl; + } + + @NonNull + public String getUploader() { + return uploader; + } + + @NonNull + public StreamType getStreamType() { + return streamType; + } + + public long getRecoveryPosition() { + return recoveryPosition; + } + + /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { + this.recoveryPosition = recoveryPosition; + } + + @Nullable + public Throwable getError() { + return error; + } + + @NonNull + public Single getStream() { + return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) + .subscribeOn(Schedulers.io()) + .doOnError(throwable -> error = throwable); + } + + public boolean isAutoQueued() { + return isAutoQueued; + } + + //////////////////////////////////////////////////////////////////////////// + // Item States, keep external access out + //////////////////////////////////////////////////////////////////////////// + + public void setAutoQueued(final boolean autoQueued) { + isAutoQueued = autoQueued; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemBuilder.java new file mode 100644 index 000000000..d95a19b65 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemBuilder.java @@ -0,0 +1,77 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import android.content.Context; +import android.text.TextUtils; +import android.view.MotionEvent; +import android.view.View; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.util.ImageDisplayConstants; +import org.schabi.newpipelegacy.util.Localization; + +public class PlayQueueItemBuilder { + private static final String TAG = PlayQueueItemBuilder.class.toString(); + private OnSelectedListener onItemClickListener; + + public PlayQueueItemBuilder(final Context context) { + } + + public void setOnSelectedListener(final OnSelectedListener listener) { + this.onItemClickListener = listener; + } + + public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueueItem item) { + if (!TextUtils.isEmpty(item.getTitle())) { + holder.itemVideoTitleView.setText(item.getTitle()); + } + holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), + NewPipe.getNameOfService(item.getServiceId()))); + + if (item.getDuration() > 0) { + holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); + } else { + holder.itemDurationView.setVisibility(View.GONE); + } + + ImageLoader.getInstance().displayImage(item.getThumbnailUrl(), holder.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS); + + holder.itemRoot.setOnClickListener(view -> { + if (onItemClickListener != null) { + onItemClickListener.selected(item, view); + } + }); + + holder.itemRoot.setOnLongClickListener(view -> { + if (onItemClickListener != null) { + onItemClickListener.held(item, view); + return true; + } + return false; + }); + + holder.itemThumbnailView.setOnTouchListener(getOnTouchListener(holder)); + holder.itemHandle.setOnTouchListener(getOnTouchListener(holder)); + } + + private View.OnTouchListener getOnTouchListener(final PlayQueueItemHolder holder) { + return (view, motionEvent) -> { + view.performClick(); + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN + && onItemClickListener != null) { + onItemClickListener.onStartDrag(holder); + } + return false; + }; + } + + public interface OnSelectedListener { + void selected(PlayQueueItem item, View view); + + void held(PlayQueueItem item, View view); + + void onStartDrag(PlayQueueItemHolder viewHolder); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemHolder.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemHolder.java new file mode 100644 index 000000000..11000d44e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemHolder.java @@ -0,0 +1,56 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import android.view.View; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; + +/** + * Created by Christian Schabesberger on 01.08.16. + *

+ * Copyright (C) Christian Schabesberger 2016 + * StreamInfoItemHolder.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 PlayQueueItemHolder extends RecyclerView.ViewHolder { + public final TextView itemVideoTitleView; + public final TextView itemDurationView; + final TextView itemAdditionalDetailsView; + + final ImageView itemSelected; + public final ImageView itemThumbnailView; + final ImageView itemHandle; + + public final View itemRoot; + + PlayQueueItemHolder(final View v) { + super(v); + itemRoot = v.findViewById(R.id.itemRoot); + itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); + itemDurationView = v.findViewById(R.id.itemDurationView); + itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); + itemSelected = v.findViewById(R.id.itemSelected); + itemThumbnailView = v.findViewById(R.id.itemThumbnailView); + itemHandle = v.findViewById(R.id.itemHandle); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemTouchCallback.java new file mode 100644 index 000000000..16498bf29 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlayQueueItemTouchCallback.java @@ -0,0 +1,56 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleCallback { + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; + private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; + + public PlayQueueItemTouchCallback() { + super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT); + } + + public abstract void onMove(int sourceIndex, int targetIndex); + + public abstract void onSwiped(int index); + + @Override + public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, final int viewSize, + final int viewSizeOutOfBounds, final int totalSize, + final long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int clampedAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, + Math.min(Math.abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)); + return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(final RecyclerView recyclerView, final RecyclerView.ViewHolder source, + final RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType()) { + return false; + } + + final int sourceIndex = source.getLayoutPosition(); + final int targetIndex = target.getLayoutPosition(); + onMove(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + onSwiped(viewHolder.getAdapterPosition()); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlaylistPlayQueue.java new file mode 100644 index 000000000..64828371c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/PlaylistPlayQueue.java @@ -0,0 +1,50 @@ +package org.schabi.newpipelegacy.player.playqueue; + +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.util.ExtractorHelper; + +import java.util.List; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; + +public final class PlaylistPlayQueue extends AbstractInfoPlayQueue { + public PlaylistPlayQueue(final PlaylistInfoItem item) { + super(item); + } + + public PlaylistPlayQueue(final PlaylistInfo info) { + this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + } + + public PlaylistPlayQueue(final int serviceId, + final String url, + final Page nextPage, + final List streams, + final int index) { + super(serviceId, url, nextPage, streams, index); + } + + @Override + protected String getTag() { + return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()); + } + + @Override + public void fetch() { + if (this.isInitial) { + ExtractorHelper.getPlaylistInfo(this.serviceId, this.baseUrl, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } else { + ExtractorHelper.getMorePlaylistItems(this.serviceId, this.baseUrl, this.nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/SinglePlayQueue.java new file mode 100644 index 000000000..3ea3658db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/SinglePlayQueue.java @@ -0,0 +1,44 @@ +package org.schabi.newpipelegacy.player.playqueue; + +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 final class SinglePlayQueue extends PlayQueue { + public SinglePlayQueue(final StreamInfoItem item) { + super(0, Collections.singletonList(new PlayQueueItem(item))); + } + + public SinglePlayQueue(final StreamInfo info) { + super(0, Collections.singletonList(new PlayQueueItem(info))); + } + + public SinglePlayQueue(final StreamInfo info, final long startPosition) { + super(0, Collections.singletonList(new PlayQueueItem(info))); + getItem().setRecoveryPosition(startPosition); + } + + public SinglePlayQueue(final List items, final int index) { + super(index, playQueueItemsOf(items)); + } + + private static List playQueueItemsOf(final List items) { + List playQueueItems = new ArrayList<>(items.size()); + for (final StreamInfoItem item : items) { + playQueueItems.add(new PlayQueueItem(item)); + } + return playQueueItems; + } + + @Override + public boolean isComplete() { + return true; + } + + @Override + public void fetch() { + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/AppendEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/AppendEvent.java new file mode 100644 index 000000000..7603355bc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/AppendEvent.java @@ -0,0 +1,18 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class AppendEvent implements PlayQueueEvent { + private final int amount; + + public AppendEvent(final int amount) { + this.amount = amount; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.APPEND; + } + + public int getAmount() { + return amount; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ErrorEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ErrorEvent.java new file mode 100644 index 000000000..f154c2a34 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ErrorEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class ErrorEvent implements PlayQueueEvent { + private final int errorIndex; + private final int queueIndex; + + public ErrorEvent(final int errorIndex, final int queueIndex) { + this.errorIndex = errorIndex; + this.queueIndex = queueIndex; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.ERROR; + } + + public int getErrorIndex() { + return errorIndex; + } + + public int getQueueIndex() { + return queueIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/InitEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/InitEvent.java new file mode 100644 index 000000000..cb554aca9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/InitEvent.java @@ -0,0 +1,8 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class InitEvent implements PlayQueueEvent { + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.INIT; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/MoveEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/MoveEvent.java new file mode 100644 index 000000000..059730e50 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/MoveEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class MoveEvent implements PlayQueueEvent { + private final int fromIndex; + private final int toIndex; + + public MoveEvent(final int oldIndex, final int newIndex) { + this.fromIndex = oldIndex; + this.toIndex = newIndex; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.MOVE; + } + + public int getFromIndex() { + return fromIndex; + } + + public int getToIndex() { + return toIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEvent.java new file mode 100644 index 000000000..c105da65e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEvent.java @@ -0,0 +1,7 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +import java.io.Serializable; + +public interface PlayQueueEvent extends Serializable { + PlayQueueEventType type(); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEventType.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEventType.java new file mode 100644 index 000000000..6e50a10d8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/PlayQueueEventType.java @@ -0,0 +1,27 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public enum PlayQueueEventType { + INIT, + + // sent when the index is changed + SELECT, + + // sent when more streams are added to the play queue + APPEND, + + // sent when a pending stream is removed from the play queue + REMOVE, + + // sent when two streams swap place in the play queue + MOVE, + + // sent when queue is shuffled + REORDER, + + // sent when recovery record is set on a stream + RECOVERY, + + // sent when the item at index has caused an exception + ERROR +} + diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RecoveryEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RecoveryEvent.java new file mode 100644 index 000000000..cec72c04c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RecoveryEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class RecoveryEvent implements PlayQueueEvent { + private final int index; + private final long position; + + public RecoveryEvent(final int index, final long position) { + this.index = index; + this.position = position; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.RECOVERY; + } + + public int getIndex() { + return index; + } + + public long getPosition() { + return position; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RemoveEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RemoveEvent.java new file mode 100644 index 000000000..6e8c22b49 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/RemoveEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class RemoveEvent implements PlayQueueEvent { + private final int removeIndex; + private final int queueIndex; + + public RemoveEvent(final int removeIndex, final int queueIndex) { + this.removeIndex = removeIndex; + this.queueIndex = queueIndex; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.REMOVE; + } + + public int getQueueIndex() { + return queueIndex; + } + + public int getRemoveIndex() { + return removeIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ReorderEvent.java new file mode 100644 index 000000000..194e9eff2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/ReorderEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class ReorderEvent implements PlayQueueEvent { + private final int fromSelectedIndex; + private final int toSelectedIndex; + + public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) { + this.fromSelectedIndex = fromSelectedIndex; + this.toSelectedIndex = toSelectedIndex; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.REORDER; + } + + public int getFromSelectedIndex() { + return fromSelectedIndex; + } + + public int getToSelectedIndex() { + return toSelectedIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/SelectEvent.java b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/SelectEvent.java new file mode 100644 index 000000000..1fa868040 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/playqueue/events/SelectEvent.java @@ -0,0 +1,24 @@ +package org.schabi.newpipelegacy.player.playqueue.events; + +public class SelectEvent implements PlayQueueEvent { + private final int oldIndex; + private final int newIndex; + + public SelectEvent(final int oldIndex, final int newIndex) { + this.oldIndex = oldIndex; + this.newIndex = newIndex; + } + + @Override + public PlayQueueEventType type() { + return PlayQueueEventType.SELECT; + } + + public int getOldIndex() { + return oldIndex; + } + + public int getNewIndex() { + return newIndex; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/AudioPlaybackResolver.java new file mode 100644 index 000000000..78964732f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/AudioPlaybackResolver.java @@ -0,0 +1,47 @@ +package org.schabi.newpipelegacy.player.resolver; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.source.MediaSource; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.player.helper.PlayerDataSource; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.util.ListHelper; + +public class AudioPlaybackResolver implements PlaybackResolver { + @NonNull + private final Context context; + @NonNull + private final PlayerDataSource dataSource; + + public AudioPlaybackResolver(@NonNull final Context context, + @NonNull final PlayerDataSource dataSource) { + this.context = context; + this.dataSource = dataSource; + } + + @Override + @Nullable + public MediaSource resolve(@NonNull final StreamInfo info) { + final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + if (liveSource != null) { + return liveSource; + } + + final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); + if (index < 0 || index >= info.getAudioStreams().size()) { + return null; + } + + final AudioStream audio = info.getAudioStreams().get(index); + final MediaSourceTag tag = new MediaSourceTag(info); + return buildMediaSource(dataSource, audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), + MediaFormat.getSuffixById(audio.getFormatId()), tag); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/resolver/MediaSourceTag.java b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/MediaSourceTag.java new file mode 100644 index 000000000..bf37dab59 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/MediaSourceTag.java @@ -0,0 +1,53 @@ +package org.schabi.newpipelegacy.player.resolver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.io.Serializable; +import java.util.Collections; +import java.util.List; + +public class MediaSourceTag implements Serializable { + @NonNull + private final StreamInfo metadata; + + @NonNull + private final List sortedAvailableVideoStreams; + private final int selectedVideoStreamIndex; + + public MediaSourceTag(@NonNull final StreamInfo metadata, + @NonNull final List sortedAvailableVideoStreams, + final int selectedVideoStreamIndex) { + this.metadata = metadata; + this.sortedAvailableVideoStreams = sortedAvailableVideoStreams; + this.selectedVideoStreamIndex = selectedVideoStreamIndex; + } + + public MediaSourceTag(@NonNull final StreamInfo metadata) { + this(metadata, Collections.emptyList(), /*indexNotAvailable=*/-1); + } + + @NonNull + public StreamInfo getMetadata() { + return metadata; + } + + @NonNull + public List getSortedAvailableVideoStreams() { + return sortedAvailableVideoStreams; + } + + public int getSelectedVideoStreamIndex() { + return selectedVideoStreamIndex; + } + + @Nullable + public VideoStream getSelectedVideoStream() { + return selectedVideoStreamIndex < 0 + || selectedVideoStreamIndex >= sortedAvailableVideoStreams.size() + ? null : sortedAvailableVideoStreams.get(selectedVideoStreamIndex); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/PlaybackResolver.java new file mode 100644 index 000000000..c72d1e048 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/PlaybackResolver.java @@ -0,0 +1,85 @@ +package org.schabi.newpipelegacy.player.resolver; + +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.util.Util; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipelegacy.player.helper.PlayerDataSource; + +public interface PlaybackResolver extends Resolver { + + @Nullable + default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final StreamInfo info) { + final StreamType streamType = info.getStreamType(); + if (!(streamType == StreamType.AUDIO_LIVE_STREAM || streamType == StreamType.LIVE_STREAM)) { + return null; + } + + final MediaSourceTag tag = new MediaSourceTag(info); + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + } + + return null; + } + + @NonNull + default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final String sourceUrl, + @C.ContentType final int type, + @NonNull final MediaSourceTag metadata) { + final Uri uri = Uri.parse(sourceUrl); + switch (type) { + case C.TYPE_SS: + return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + case C.TYPE_DASH: + return dataSource.getLiveDashMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + case C.TYPE_HLS: + return dataSource.getLiveHlsMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + default: + throw new IllegalStateException("Unsupported type: " + type); + } + } + + @NonNull + default MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, + @NonNull final String sourceUrl, + @NonNull final String cacheKey, + @NonNull final String overrideExtension, + @NonNull final MediaSourceTag metadata) { + final Uri uri = Uri.parse(sourceUrl); + @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) + ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); + + switch (type) { + case C.TYPE_SS: + return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + case C.TYPE_DASH: + return dataSource.getDashMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + case C.TYPE_HLS: + return dataSource.getHlsMediaSourceFactory().setTag(metadata) + .createMediaSource(uri); + case C.TYPE_OTHER: + return dataSource.getExtractorMediaSourceFactory(cacheKey).setTag(metadata) + .createMediaSource(uri); + default: + throw new IllegalStateException("Unsupported type: " + type); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/resolver/Resolver.java b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/Resolver.java new file mode 100644 index 000000000..9d18b4139 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/Resolver.java @@ -0,0 +1,9 @@ +package org.schabi.newpipelegacy.player.resolver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public interface Resolver { + @Nullable + Product resolve(@NonNull Source source); +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/VideoPlaybackResolver.java new file mode 100644 index 000000000..0b9a2aa8a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/player/resolver/VideoPlaybackResolver.java @@ -0,0 +1,136 @@ +package org.schabi.newpipelegacy.player.resolver; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.Format; +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.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.newpipelegacy.player.helper.PlayerDataSource; +import org.schabi.newpipelegacy.player.helper.PlayerHelper; +import org.schabi.newpipelegacy.util.ListHelper; + +import java.util.ArrayList; +import java.util.List; + +import static com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT; +import static com.google.android.exoplayer2.C.TIME_UNSET; + +public class VideoPlaybackResolver implements PlaybackResolver { + @NonNull + private final Context context; + @NonNull + private final PlayerDataSource dataSource; + @NonNull + private final QualityResolver qualityResolver; + + @Nullable + private String playbackQuality; + + public VideoPlaybackResolver(@NonNull final Context context, + @NonNull final PlayerDataSource dataSource, + @NonNull final QualityResolver qualityResolver) { + this.context = context; + this.dataSource = dataSource; + this.qualityResolver = qualityResolver; + } + + @Override + @Nullable + public MediaSource resolve(@NonNull final StreamInfo info) { + final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + if (liveSource != null) { + return liveSource; + } + + List mediaSources = new ArrayList<>(); + + // Create video stream source + final List videos = ListHelper.getSortedStreamVideosList(context, + info.getVideoStreams(), info.getVideoOnlyStreams(), false); + final int index; + if (videos.isEmpty()) { + index = -1; + } else if (playbackQuality == null) { + index = qualityResolver.getDefaultResolutionIndex(videos); + } else { + index = qualityResolver.getOverrideResolutionIndex(videos, getPlaybackQuality()); + } + final MediaSourceTag tag = new MediaSourceTag(info, videos, index); + @Nullable final VideoStream video = tag.getSelectedVideoStream(); + + if (video != null) { + final MediaSource streamSource = buildMediaSource(dataSource, video.getUrl(), + PlayerHelper.cacheKeyOf(info, video), + MediaFormat.getSuffixById(video.getFormatId()), tag); + mediaSources.add(streamSource); + } + + // Create optional audio stream source + final List audioStreams = info.getAudioStreams(); + final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get( + ListHelper.getDefaultAudioFormat(context, audioStreams)); + // Use the audio stream if there is no video stream, or + // Merge with audio stream in case if video does not contain audio + if (audio != null && (video == null || video.isVideoOnly)) { + final MediaSource audioSource = buildMediaSource(dataSource, audio.getUrl(), + PlayerHelper.cacheKeyOf(info, audio), + MediaFormat.getSuffixById(audio.getFormatId()), tag); + mediaSources.add(audioSource); + } + + // If there is no audio or video sources, then this media source cannot be played back + if (mediaSources.isEmpty()) { + return null; + } + // Below are auxiliary media sources + + // Create subtitle sources + if (info.getSubtitles() != null) { + for (final SubtitlesStream subtitle : info.getSubtitles()) { + final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); + if (mimeType == null) { + continue; + } + + final Format textFormat = Format.createTextSampleFormat(null, mimeType, + SELECTION_FLAG_AUTOSELECT, + PlayerHelper.captionLanguageOf(context, subtitle)); + final MediaSource textSource = dataSource.getSampleMediaSourceFactory() + .createMediaSource(Uri.parse(subtitle.getURL()), textFormat, TIME_UNSET); + mediaSources.add(textSource); + } + } + + if (mediaSources.size() == 1) { + return mediaSources.get(0); + } else { + return new MergingMediaSource(mediaSources.toArray( + new MediaSource[mediaSources.size()])); + } + } + + @Nullable + public String getPlaybackQuality() { + return playbackQuality; + } + + public void setPlaybackQuality(@Nullable final String playbackQuality) { + this.playbackQuality = playbackQuality; + } + + public interface QualityResolver { + int getDefaultResolutionIndex(List sortedVideos); + + int getOverrideResolutionIndex(List sortedVideos, String playbackQuality); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSender.java b/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSender.java new file mode 100644 index 000000000..6b6ac0ac7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSender.java @@ -0,0 +1,39 @@ +package org.schabi.newpipelegacy.report; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.acra.data.CrashReportData; +import org.acra.sender.ReportSender; +import org.schabi.newpipelegacy.R; + +/* + * Created by Christian Schabesberger on 13.09.16. + * + * Copyright (C) Christian Schabesberger 2015 + * AcraReportSender.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class AcraReportSender implements ReportSender { + + @Override + public void send(@NonNull final Context context, @NonNull final CrashReportData report) { + ErrorActivity.reportError(context, report, + ErrorActivity.ErrorInfo.make(UserAction.UI_ERROR, "none", + "App crash, UI failure", R.string.app_ui_crash)); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSenderFactory.java b/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSenderFactory.java new file mode 100644 index 000000000..e0562bae4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/report/AcraReportSenderFactory.java @@ -0,0 +1,37 @@ +package org.schabi.newpipelegacy.report; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.acra.config.CoreConfiguration; +import org.acra.sender.ReportSender; +import org.acra.sender.ReportSenderFactory; + +/* + * Created by Christian Schabesberger on 13.09.16. + * + * Copyright (C) Christian Schabesberger 2015 + * AcraReportSenderFactory.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ + +public class AcraReportSenderFactory implements ReportSenderFactory { + @NonNull + public ReportSender create(@NonNull final Context context, + @NonNull final CoreConfiguration config) { + return new AcraReportSender(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/report/ErrorActivity.java b/app/src/main/java/org/schabi/newpipelegacy/report/ErrorActivity.java new file mode 100644 index 000000000..9384913d0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/report/ErrorActivity.java @@ -0,0 +1,551 @@ +package org.schabi.newpipelegacy.report; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.Log; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.core.app.NavUtils; + +import com.google.android.material.snackbar.Snackbar; +import com.grack.nanojson.JsonWriter; + +import org.acra.ReportField; +import org.acra.data.CrashReportData; +import org.schabi.newpipelegacy.ActivityCommunicator; +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.Localization; +import org.schabi.newpipelegacy.util.ShareUtils; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.TimeZone; +import java.util.Vector; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +/* + * Created by Christian Schabesberger on 24.10.15. + * + * Copyright (C) Christian Schabesberger 2016 + * ErrorActivity.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 ErrorActivity extends AppCompatActivity { + // LOG TAGS + public static final String TAG = ErrorActivity.class.toString(); + // BUNDLE TAGS + public static final String ERROR_INFO = "error_info"; + public static final String ERROR_LIST = "error_list"; + + public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"; + public static final String ERROR_EMAIL_SUBJECT + = "Exception in " + R.string.app_name + ' ' + BuildConfig.VERSION_NAME; + + public static final String ERROR_GITHUB_ISSUE_URL + = "https://github.com/TeamNewPipe/NewPipe-legacy/issues"; + + private String[] errorList; + private ErrorInfo errorInfo; + private Class returnActivity; + private String currentTimeStamp; + private EditText userCommentBox; + + public static void reportUiError(final AppCompatActivity activity, final Throwable el) { + reportError(activity, el, activity.getClass(), null, ErrorInfo.make(UserAction.UI_ERROR, + "none", "", R.string.app_ui_crash)); + } + + public static void reportError(final Context context, final List el, + final Class returnActivity, final View rootView, + final ErrorInfo errorInfo) { + if (rootView != null) { + Snackbar.make(rootView, R.string.error_snackbar_message, 3 * 1000) + .setActionTextColor(Color.YELLOW) + .setAction(context.getString(R.string.error_snackbar_action).toUpperCase(), v -> + startErrorActivity(returnActivity, context, errorInfo, el)).show(); + } else { + startErrorActivity(returnActivity, context, errorInfo, el); + } + } + + private static void startErrorActivity(final Class returnActivity, final Context context, + final ErrorInfo errorInfo, final List el) { + ActivityCommunicator ac = ActivityCommunicator.getCommunicator(); + ac.setReturnActivity(returnActivity); + Intent intent = new Intent(context, ErrorActivity.class); + intent.putExtra(ERROR_INFO, errorInfo); + intent.putExtra(ERROR_LIST, elToSl(el)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + public static void reportError(final Context context, final Throwable e, + final Class returnActivity, final View rootView, + final ErrorInfo errorInfo) { + List el = null; + if (e != null) { + el = new Vector<>(); + el.add(e); + } + reportError(context, el, returnActivity, rootView, errorInfo); + } + + // async call + public static void reportError(final Handler handler, final Context context, + final Throwable e, final Class returnActivity, + final View rootView, final ErrorInfo errorInfo) { + + List el = null; + if (e != null) { + el = new Vector<>(); + el.add(e); + } + reportError(handler, context, el, returnActivity, rootView, errorInfo); + } + + // async call + public static void reportError(final Handler handler, final Context context, + final List el, final Class returnActivity, + final View rootView, final ErrorInfo errorInfo) { + handler.post(() -> reportError(context, el, returnActivity, rootView, errorInfo)); + } + + public static void reportError(final Context context, final CrashReportData report, + final ErrorInfo errorInfo) { + String[] el = new String[]{report.getString(ReportField.STACK_TRACE)}; + + Intent intent = new Intent(context, ErrorActivity.class); + intent.putExtra(ERROR_INFO, errorInfo); + intent.putExtra(ERROR_LIST, el); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + private static String getStackTrace(final Throwable throwable) { + final StringWriter sw = new StringWriter(); + final PrintWriter pw = new PrintWriter(sw, true); + throwable.printStackTrace(pw); + return sw.getBuffer().toString(); + } + + // errorList to StringList + private static String[] elToSl(final List stackTraces) { + String[] out = new String[stackTraces.size()]; + for (int i = 0; i < stackTraces.size(); i++) { + out[i] = getStackTrace(stackTraces.get(i)); + } + return out; + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceState); + ThemeHelper.setTheme(this); + setContentView(R.layout.activity_error); + + Intent intent = getIntent(); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setTitle(R.string.error_report_title); + actionBar.setDisplayShowTitleEnabled(true); + } + + final Button reportEmailButton = findViewById(R.id.errorReportEmailButton); + final Button copyButton = findViewById(R.id.errorReportCopyButton); + final Button reportGithubButton = findViewById(R.id.errorReportGitHubButton); + + userCommentBox = findViewById(R.id.errorCommentBox); + TextView errorView = findViewById(R.id.errorView); + TextView infoView = findViewById(R.id.errorInfosView); + TextView errorMessageView = findViewById(R.id.errorMessageView); + + ActivityCommunicator ac = ActivityCommunicator.getCommunicator(); + returnActivity = ac.getReturnActivity(); + errorInfo = intent.getParcelableExtra(ERROR_INFO); + errorList = intent.getStringArrayExtra(ERROR_LIST); + + // important add guru meditation + addGuruMeditation(); + currentTimeStamp = getCurrentTimeStamp(); + + reportEmailButton.setOnClickListener((View v) -> { + openPrivacyPolicyDialog(this, "EMAIL"); + }); + + copyButton.setOnClickListener((View v) -> { + ShareUtils.copyToClipboard(this, buildMarkdown()); + Toast.makeText(this, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + }); + + reportGithubButton.setOnClickListener((View v) -> { + openPrivacyPolicyDialog(this, "GITHUB"); + }); + + // normal bugreport + buildInfo(errorInfo); + if (errorInfo.message != 0) { + errorMessageView.setText(errorInfo.message); + } else { + errorMessageView.setVisibility(View.GONE); + findViewById(R.id.messageWhatHappenedView).setVisibility(View.GONE); + } + + errorView.setText(formErrorText(errorList)); + + // print stack trace once again for debugging: + for (String e : errorList) { + Log.e(TAG, e); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.error_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + switch (id) { + case android.R.id.home: + goToReturnActivity(); + break; + case R.id.menu_item_share_error: + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_SEND); + intent.putExtra(Intent.EXTRA_TEXT, buildJson()); + intent.setType("text/plain"); + startActivity(Intent.createChooser(intent, getString(R.string.share_dialog_title))); + break; + } + return false; + } + + private void openPrivacyPolicyDialog(final Context context, final String action) { + new AlertDialog.Builder(context) + .setIcon(android.R.drawable.ic_dialog_alert) + .setTitle(R.string.privacy_policy_title) + .setMessage(R.string.start_accept_privacy_policy) + .setCancelable(false) + .setNeutralButton(R.string.read_privacy_policy, (dialog, which) -> { + ShareUtils.openUrlInBrowser(context, + context.getString(R.string.privacy_policy_url)); + }) + .setPositiveButton(R.string.accept, (dialog, which) -> { + if (action.equals("EMAIL")) { // send on email + final Intent i = new Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse("mailto:")) // only email apps should handle this + .putExtra(Intent.EXTRA_EMAIL, new String[]{ERROR_EMAIL_ADDRESS}) + .putExtra(Intent.EXTRA_SUBJECT, ERROR_EMAIL_SUBJECT) + .putExtra(Intent.EXTRA_TEXT, buildJson()); + if (i.resolveActivity(getPackageManager()) != null) { + startActivity(i); + } + } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub + ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL); + } + + }) + .setNegativeButton(R.string.decline, (dialog, which) -> { + // do nothing + }) + .show(); + } + + private String formErrorText(final String[] el) { + StringBuilder text = new StringBuilder(); + if (el != null) { + for (String e : el) { + text.append("-------------------------------------\n").append(e); + } + } + text.append("-------------------------------------"); + return text.toString(); + } + + /** + * Get the checked activity. + * + * @param returnActivity the activity to return to + * @return the casted return activity or null + */ + @Nullable + static Class getReturnActivity(final Class returnActivity) { + Class checkedReturnActivity = null; + if (returnActivity != null) { + if (Activity.class.isAssignableFrom(returnActivity)) { + checkedReturnActivity = returnActivity.asSubclass(Activity.class); + } else { + checkedReturnActivity = MainActivity.class; + } + } + return checkedReturnActivity; + } + + private void goToReturnActivity() { + Class checkedReturnActivity = getReturnActivity(returnActivity); + if (checkedReturnActivity == null) { + super.onBackPressed(); + } else { + Intent intent = new Intent(this, checkedReturnActivity); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + NavUtils.navigateUpTo(this, intent); + } + } + + private void buildInfo(final ErrorInfo info) { + TextView infoLabelView = findViewById(R.id.errorInfoLabelsView); + TextView infoView = findViewById(R.id.errorInfosView); + String text = ""; + + infoLabelView.setText(getString(R.string.info_labels).replace("\\n", "\n")); + + text += getUserActionString(info.userAction) + "\n" + + info.request + "\n" + + getContentLanguageString() + "\n" + + getContentCountryString() + "\n" + + getAppLanguage() + "\n" + + info.serviceName + "\n" + + currentTimeStamp + "\n" + + getPackageName() + "\n" + + BuildConfig.VERSION_NAME + "\n" + + getOsString(); + + infoView.setText(text); + } + + private String buildJson() { + try { + return JsonWriter.string() + .object() + .value("user_action", getUserActionString(errorInfo.userAction)) + .value("request", errorInfo.request) + .value("content_language", getContentLanguageString()) + .value("content_country", getContentCountryString()) + .value("app_language", getAppLanguage()) + .value("service", errorInfo.serviceName) + .value("package", getPackageName()) + .value("version", BuildConfig.VERSION_NAME) + .value("os", getOsString()) + .value("time", currentTimeStamp) + .array("exceptions", Arrays.asList(errorList)) + .value("user_comment", userCommentBox.getText().toString()) + .end() + .done(); + } catch (Throwable e) { + Log.e(TAG, "Error while erroring: Could not build json"); + e.printStackTrace(); + } + + return ""; + } + + private String buildMarkdown() { + try { + final StringBuilder htmlErrorReport = new StringBuilder(); + + final String userComment = userCommentBox.getText().toString(); + if (!userComment.isEmpty()) { + htmlErrorReport.append(userComment).append("\n"); + } + + // basic error info + htmlErrorReport + .append("## Exception") + .append("\n* __User Action:__ ") + .append(getUserActionString(errorInfo.userAction)) + .append("\n* __Request:__ ").append(errorInfo.request) + .append("\n* __Content Country:__ ").append(getContentCountryString()) + .append("\n* __Content Language:__ ").append(getContentLanguageString()) + .append("\n* __App Language:__ ").append(getAppLanguage()) + .append("\n* __Service:__ ").append(errorInfo.serviceName) + .append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME) + .append("\n* __OS:__ ").append(getOsString()).append("\n"); + + + // Collapse all logs to a single paragraph when there are more than one + // to keep the GitHub issue clean. + if (errorList.length > 1) { + htmlErrorReport + .append("
Exceptions (") + .append(errorList.length) + .append(")

\n"); + } + + // add the logs + for (int i = 0; i < errorList.length; i++) { + htmlErrorReport.append("

Crash log "); + if (errorList.length > 1) { + htmlErrorReport.append(i + 1); + } + htmlErrorReport.append("") + .append("

\n") + .append("\n```\n").append(errorList[i]).append("\n```\n") + .append("

\n"); + } + + // make sure to close everything + if (errorList.length > 1) { + htmlErrorReport.append("

\n"); + } + htmlErrorReport.append("
\n"); + return htmlErrorReport.toString(); + } catch (Throwable e) { + Log.e(TAG, "Error while erroring: Could not build markdown"); + e.printStackTrace(); + return ""; + } + } + + private String getUserActionString(final UserAction userAction) { + if (userAction == null) { + return "Your description is in another castle."; + } else { + return userAction.getMessage(); + } + } + + private String getContentCountryString() { + return Localization.getPreferredContentCountry(this).getCountryCode(); + } + + private String getContentLanguageString() { + return Localization.getPreferredLocalization(this).getLocalizationCode(); + } + + private String getAppLanguage() { + return Localization.getAppLocale(getApplicationContext()).toString(); + } + + private String getOsString() { + final String osBase = Build.VERSION.SDK_INT >= 23 ? Build.VERSION.BASE_OS : "Android"; + return System.getProperty("os.name") + + " " + (osBase.isEmpty() ? "Android" : osBase) + + " " + Build.VERSION.RELEASE + + " - " + Build.VERSION.SDK_INT; + } + + private void addGuruMeditation() { + //just an easter egg + TextView sorryView = findViewById(R.id.errorSorryView); + String text = sorryView.getText().toString(); + text += "\n" + getString(R.string.guru_meditation); + sorryView.setText(text); + } + + @Override + public void onBackPressed() { + //super.onBackPressed(); + goToReturnActivity(); + } + + public String getCurrentTimeStamp() { + SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + df.setTimeZone(TimeZone.getTimeZone("GMT")); + return df.format(new Date()); + } + + public static class ErrorInfo implements Parcelable { + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public ErrorInfo createFromParcel(final Parcel source) { + return new ErrorInfo(source); + } + + @Override + public ErrorInfo[] newArray(final int size) { + return new ErrorInfo[size]; + } + }; + + final UserAction userAction; + public final String request; + final String serviceName; + @StringRes + public final int message; + + private ErrorInfo(final UserAction userAction, final String serviceName, + final String request, @StringRes final int message) { + this.userAction = userAction; + this.serviceName = serviceName; + this.request = request; + this.message = message; + } + + protected ErrorInfo(final Parcel in) { + this.userAction = UserAction.valueOf(in.readString()); + this.request = in.readString(); + this.serviceName = in.readString(); + this.message = in.readInt(); + } + + public static ErrorInfo make(final UserAction userAction, final String serviceName, + final String request, @StringRes final int message) { + return new ErrorInfo(userAction, serviceName, request, message); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(this.userAction.name()); + dest.writeString(this.request); + dest.writeString(this.serviceName); + dest.writeInt(this.message); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/report/UserAction.java b/app/src/main/java/org/schabi/newpipelegacy/report/UserAction.java new file mode 100644 index 000000000..27435b838 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/report/UserAction.java @@ -0,0 +1,35 @@ +package org.schabi.newpipelegacy.report; + +/** + * The user actions that can cause an error. + */ +public enum UserAction { + USER_REPORT("user report"), + UI_ERROR("ui error"), + SUBSCRIPTION("subscription"), + LOAD_IMAGE("load image"), + SOMETHING_ELSE("something"), + SEARCHED("searched"), + GET_SUGGESTIONS("get suggestions"), + REQUESTED_STREAM("requested stream"), + REQUESTED_CHANNEL("requested channel"), + REQUESTED_PLAYLIST("requested playlist"), + REQUESTED_KIOSK("requested kiosk"), + REQUESTED_COMMENTS("requested comments"), + REQUESTED_FEED("requested feed"), + DELETE_FROM_HISTORY("delete from history"), + PLAY_STREAM("Play stream"), + DOWNLOAD_POSTPROCESSING("download post-processing"), + DOWNLOAD_FAILED("download failed"); + + + private final String message; + + UserAction(final String message) { + this.message = message; + } + + public String getMessage() { + return message; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/AppearanceSettingsFragment.java new file mode 100644 index 000000000..9339948b2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/AppearanceSettingsFragment.java @@ -0,0 +1,74 @@ +package org.schabi.newpipelegacy.settings; + +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.Constants; + +public class AppearanceSettingsFragment extends BasePreferenceFragment { + private static final boolean CAPTIONING_SETTINGS_ACCESSIBLE = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; + + /** + * Theme that was applied when the settings was opened (or recreated after a theme change). + */ + private String startThemeKey; + private final Preference.OnPreferenceChangeListener themePreferenceChange + = new Preference.OnPreferenceChangeListener() { + @Override + public boolean onPreferenceChange(final Preference preference, final Object newValue) { + defaultPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, true).apply(); + defaultPreferences.edit() + .putString(getString(R.string.theme_key), newValue.toString()).apply(); + + if (!newValue.equals(startThemeKey) && getActivity() != null) { + // If it's not the current theme + getActivity().recreate(); + } + + return false; + } + }; + private String captionSettingsKey; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + String themeKey = getString(R.string.theme_key); + startThemeKey = defaultPreferences + .getString(themeKey, getString(R.string.default_theme_value)); + findPreference(themeKey).setOnPreferenceChangeListener(themePreferenceChange); + + captionSettingsKey = getString(R.string.caption_settings_key); + if (!CAPTIONING_SETTINGS_ACCESSIBLE) { + final Preference captionSettings = findPreference(captionSettingsKey); + getPreferenceScreen().removePreference(captionSettings); + } + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.appearance_settings); + } + + @Override + public boolean onPreferenceTreeClick(final Preference preference) { + if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) { + try { + startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS)); + } catch (ActivityNotFoundException e) { + Toast.makeText(getActivity(), R.string.general_error, Toast.LENGTH_SHORT).show(); + } + } + + return super.onPreferenceTreeClick(preference); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/BasePreferenceFragment.java new file mode 100644 index 000000000..5bdf13730 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/BasePreferenceFragment.java @@ -0,0 +1,48 @@ +package org.schabi.newpipelegacy.settings; + +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.View; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceFragmentCompat; + +import org.schabi.newpipelegacy.MainActivity; + +public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { + protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); + protected final boolean DEBUG = MainActivity.DEBUG; + + SharedPreferences defaultPreferences; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + defaultPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + super.onCreate(savedInstanceState); + } + + @Override + public void onViewCreated(final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + setDivider(null); + updateTitle(); + } + + @Override + public void onResume() { + super.onResume(); + updateTitle(); + } + + private void updateTitle() { + if (getActivity() instanceof AppCompatActivity) { + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(getPreferenceScreen().getTitle()); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/ContentSettingsFragment.java new file mode 100644 index 000000000..d087f683c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/ContentSettingsFragment.java @@ -0,0 +1,353 @@ +package org.schabi.newpipelegacy.settings; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import com.nononsenseapps.filepicker.Utils; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipelegacy.DownloaderImpl; +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.Localization; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.FilePickerActivityHelper; +import org.schabi.newpipelegacy.util.ZipHelper; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Map; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class ContentSettingsFragment extends BasePreferenceFragment { + private static final int REQUEST_IMPORT_PATH = 8945; + private static final int REQUEST_EXPORT_PATH = 30945; + + private File databasesDir; + private File newpipeDb; + private File newpipeDbJournal; + private File newpipeDbShm; + private File newpipeDbWal; + private File newpipeSettings; + + private String thumbnailLoadToggleKey; + private String youtubeRestrictedModeEnabledKey; + + private Localization initialSelectedLocalization; + private ContentCountry initialSelectedContentCountry; + private String initialLanguage; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); + youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); + + initialSelectedLocalization = org.schabi.newpipelegacy.util.Localization + .getPreferredLocalization(requireContext()); + initialSelectedContentCountry = org.schabi.newpipelegacy.util.Localization + .getPreferredContentCountry(requireContext()); + initialLanguage = PreferenceManager + .getDefaultSharedPreferences(getContext()).getString("app_language_key", "en"); + } + + @Override + public boolean onPreferenceTreeClick(final Preference preference) { + if (preference.getKey().equals(thumbnailLoadToggleKey)) { + final ImageLoader imageLoader = ImageLoader.getInstance(); + imageLoader.stop(); + imageLoader.clearDiskCache(); + imageLoader.clearMemoryCache(); + imageLoader.resume(); + Toast.makeText(preference.getContext(), R.string.thumbnail_cache_wipe_complete_notice, + Toast.LENGTH_SHORT).show(); + } + + if (preference.getKey().equals(youtubeRestrictedModeEnabledKey)) { + Context context = getContext(); + if (context != null) { + DownloaderImpl.getInstance().updateYoutubeRestrictedModeCookies(context); + } else { + Log.w(TAG, "onPreferenceTreeClick: null context"); + } + } + + return super.onPreferenceTreeClick(preference); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + + String homeDir = getActivity().getApplicationInfo().dataDir; + databasesDir = new File(homeDir + "/databases"); + newpipeDb = new File(homeDir + "/databases/newpipe.db"); + newpipeDbJournal = new File(homeDir + "/databases/newpipe.db-journal"); + newpipeDbShm = new File(homeDir + "/databases/newpipe.db-shm"); + newpipeDbWal = new File(homeDir + "/databases/newpipe.db-wal"); + + newpipeSettings = new File(homeDir + "/databases/newpipe.settings"); + newpipeSettings.delete(); + + addPreferencesFromResource(R.xml.content_settings); + + Preference importDataPreference = findPreference(getString(R.string.import_data)); + importDataPreference.setOnPreferenceClickListener((Preference p) -> { + Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_FILE); + startActivityForResult(i, REQUEST_IMPORT_PATH); + return true; + }); + + Preference exportDataPreference = findPreference(getString(R.string.export_data)); + exportDataPreference.setOnPreferenceClickListener((Preference p) -> { + Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_DIR); + startActivityForResult(i, REQUEST_EXPORT_PATH); + return true; + }); + } + + @Override + public void onDestroy() { + super.onDestroy(); + + final Localization selectedLocalization = org.schabi.newpipelegacy.util.Localization + .getPreferredLocalization(requireContext()); + final ContentCountry selectedContentCountry = org.schabi.newpipelegacy.util.Localization + .getPreferredContentCountry(requireContext()); + final String selectedLanguage = PreferenceManager + .getDefaultSharedPreferences(getContext()).getString("app_language_key", "en"); + + if (!selectedLocalization.equals(initialSelectedLocalization) + || !selectedContentCountry.equals(initialSelectedContentCountry) + || !selectedLanguage.equals(initialLanguage)) { + Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart, + Toast.LENGTH_LONG).show(); + + NewPipe.setupLocalization(selectedLocalization, selectedContentCountry); + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, + @NonNull final Intent data) { + assureCorrectAppLanguage(getContext()); + super.onActivityResult(requestCode, resultCode, data); + if (DEBUG) { + Log.d(TAG, "onActivityResult() called with: " + + "requestCode = [" + requestCode + "], " + + "resultCode = [" + resultCode + "], " + + "data = [" + data + "]"); + } + + if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) + && resultCode == Activity.RESULT_OK && data.getData() != null) { + String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + if (requestCode == REQUEST_EXPORT_PATH) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); + exportDatabase(path + "/NewPipeData-" + sdf.format(new Date()) + ".zip"); + } else { + AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setMessage(R.string.override_current_data) + .setPositiveButton(getString(R.string.finish), + (DialogInterface d, int id) -> importDatabase(path)) + .setNegativeButton(android.R.string.cancel, + (DialogInterface d, int id) -> d.cancel()); + builder.create().show(); + } + } + } + + private void exportDatabase(final String path) { + try { + //checkpoint before export + NewPipeDatabase.checkpoint(); + + ZipOutputStream outZip = new ZipOutputStream( + new BufferedOutputStream( + new FileOutputStream(path))); + ZipHelper.addFileToZip(outZip, newpipeDb.getPath(), "newpipe.db"); + + saveSharedPreferencesToFile(newpipeSettings); + ZipHelper.addFileToZip(outZip, newpipeSettings.getPath(), "newpipe.settings"); + + outZip.close(); + + Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT) + .show(); + } catch (Exception e) { + onError(e); + } + } + + private void saveSharedPreferencesToFile(final File dst) { + ObjectOutputStream output = null; + try { + output = new ObjectOutputStream(new FileOutputStream(dst)); + SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getContext()); + output.writeObject(pref.getAll()); + + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + if (output != null) { + output.flush(); + output.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + + private void importDatabase(final String filePath) { + // check if file is supported + ZipFile zipFile = null; + try { + zipFile = new ZipFile(filePath); + } catch (IOException ioe) { + Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) + .show(); + return; + } finally { + try { + zipFile.close(); + } catch (Exception ignored) { + } + } + + try { + if (!databasesDir.exists() && !databasesDir.mkdir()) { + throw new Exception("Could not create databases dir"); + } + + final boolean isDbFileExtracted = ZipHelper.extractFileFromZip(filePath, + newpipeDb.getPath(), "newpipe.db"); + + if (isDbFileExtracted) { + newpipeDbJournal.delete(); + newpipeDbWal.delete(); + newpipeDbShm.delete(); + } else { + Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG) + .show(); + } + + //If settings file exist, ask if it should be imported. + if (ZipHelper.extractFileFromZip(filePath, newpipeSettings.getPath(), + "newpipe.settings")) { + AlertDialog.Builder alert = new AlertDialog.Builder(getContext()); + alert.setTitle(R.string.import_settings); + + alert.setNegativeButton(android.R.string.no, (dialog, which) -> { + dialog.dismiss(); + // restart app to properly load db + System.exit(0); + }); + alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> { + dialog.dismiss(); + loadSharedPreferences(newpipeSettings); + // restart app to properly load db + System.exit(0); + }); + alert.show(); + } else { + // restart app to properly load db + System.exit(0); + } + } catch (Exception e) { + onError(e); + } + } + + private void loadSharedPreferences(final File src) { + ObjectInputStream input = null; + try { + input = new ObjectInputStream(new FileInputStream(src)); + SharedPreferences.Editor prefEdit = PreferenceManager + .getDefaultSharedPreferences(getContext()).edit(); + prefEdit.clear(); + Map entries = (Map) input.readObject(); + for (Map.Entry entry : entries.entrySet()) { + Object v = entry.getValue(); + String key = entry.getKey(); + + if (v instanceof Boolean) { + prefEdit.putBoolean(key, (Boolean) v); + } else if (v instanceof Float) { + prefEdit.putFloat(key, (Float) v); + } else if (v instanceof Integer) { + prefEdit.putInt(key, (Integer) v); + } else if (v instanceof Long) { + prefEdit.putLong(key, (Long) v); + } else if (v instanceof String) { + prefEdit.putString(key, (String) v); + } + } + prefEdit.commit(); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } catch (ClassNotFoundException e) { + e.printStackTrace(); + } finally { + try { + if (input != null) { + input.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Error + //////////////////////////////////////////////////////////////////////////*/ + + protected void onError(final Throwable e) { + final Activity activity = getActivity(); + ErrorActivity.reportError(activity, e, + activity.getClass(), + null, + ErrorActivity.ErrorInfo.make(UserAction.UI_ERROR, + "none", "", R.string.app_ui_crash)); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/DebugSettingsFragment.java new file mode 100644 index 000000000..739da329e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/DebugSettingsFragment.java @@ -0,0 +1,12 @@ +package org.schabi.newpipelegacy.settings; + +import android.os.Bundle; + +import org.schabi.newpipelegacy.R; + +public class DebugSettingsFragment extends BasePreferenceFragment { + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.debug_settings); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/DownloadSettingsFragment.java new file mode 100644 index 000000000..1518a6a2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/DownloadSettingsFragment.java @@ -0,0 +1,290 @@ +package org.schabi.newpipelegacy.settings; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.Preference; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.FilePickerActivityHelper; + +import java.io.File; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLDecoder; + +import us.shandian.giga.io.StoredDirectoryHelper; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +public class DownloadSettingsFragment extends BasePreferenceFragment { + public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; + private static final int REQUEST_DOWNLOAD_VIDEO_PATH = 0x1235; + private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236; + private String downloadPathVideoPreference; + private String downloadPathAudioPreference; + private String storageUseSafPreference; + + private Preference prefPathVideo; + private Preference prefPathAudio; + private Preference prefStorageAsk; + + private Context ctx; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + downloadPathVideoPreference = getString(R.string.download_path_video_key); + downloadPathAudioPreference = getString(R.string.download_path_audio_key); + storageUseSafPreference = getString(R.string.storage_use_saf); + final String downloadStorageAsk = getString(R.string.downloads_storage_ask); + + prefPathVideo = findPreference(downloadPathVideoPreference); + prefPathAudio = findPreference(downloadPathAudioPreference); + prefStorageAsk = findPreference(downloadStorageAsk); + + updatePreferencesSummary(); + updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary); + } + + if (hasInvalidPath(downloadPathVideoPreference) + || hasInvalidPath(downloadPathAudioPreference)) { + updatePreferencesSummary(); + } + + prefStorageAsk.setOnPreferenceChangeListener((preference, value) -> { + updatePathPickers(!(boolean) value); + return true; + }); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.download_settings); + } + + @Override + public void onAttach(final Context context) { + super.onAttach(context); + ctx = context; + } + + @Override + public void onDetach() { + super.onDetach(); + ctx = null; + prefStorageAsk.setOnPreferenceChangeListener(null); + } + + private void updatePreferencesSummary() { + showPathInSummary(downloadPathVideoPreference, R.string.download_path_summary, + prefPathVideo); + showPathInSummary(downloadPathAudioPreference, R.string.download_path_audio_summary, + prefPathAudio); + } + + private void showPathInSummary(final String prefKey, @StringRes final int defaultString, + final Preference target) { + String rawUri = defaultPreferences.getString(prefKey, null); + if (rawUri == null || rawUri.isEmpty()) { + target.setSummary(getString(defaultString)); + return; + } + + if (rawUri.charAt(0) == File.separatorChar) { + target.setSummary(rawUri); + return; + } + if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { + target.setSummary(new File(URI.create(rawUri)).getPath()); + return; + } + + try { + rawUri = URLDecoder.decode(rawUri, "utf-8"); + } catch (UnsupportedEncodingException e) { + // nothing to do + } + + target.setSummary(rawUri); + } + + private boolean isFileUri(final String path) { + return path.charAt(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE); + } + + private boolean hasInvalidPath(final String prefKey) { + String value = defaultPreferences.getString(prefKey, null); + return value == null || value.isEmpty(); + } + + private void updatePathPickers(final boolean enabled) { + prefPathVideo.setEnabled(enabled); + prefPathAudio.setEnabled(enabled); + } + + // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible + @SuppressLint("NewApi") + private void forgetSAFTree(final Context context, final String oldPath) { + if (IGNORE_RELEASE_ON_OLD_PATH) { + return; + } + + if (oldPath == null || oldPath.isEmpty() || isFileUri(oldPath)) { + return; + } + + try { + Uri uri = Uri.parse(oldPath); + + context.getContentResolver() + .releasePersistableUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + context.revokeUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); + + Log.i(TAG, "Revoke old path permissions success on " + oldPath); + } catch (Exception err) { + Log.e(TAG, "Error revoking old path permissions on " + oldPath, err); + } + } + + private void showMessageDialog(@StringRes final int title, @StringRes final int message) { + AlertDialog.Builder msg = new AlertDialog.Builder(ctx); + msg.setTitle(title); + msg.setMessage(message); + msg.setPositiveButton(getString(R.string.finish), null); + msg.show(); + } + + @Override + public boolean onPreferenceTreeClick(final Preference preference) { + if (DEBUG) { + Log.d(TAG, "onPreferenceTreeClick() called with: " + + "preference = [" + preference + "]"); + } + + String key = preference.getKey(); + int request; + + if (key.equals(storageUseSafPreference)) { + Toast.makeText(getContext(), R.string.download_choose_new_path, + Toast.LENGTH_LONG).show(); + return true; + } else if (key.equals(downloadPathVideoPreference)) { + request = REQUEST_DOWNLOAD_VIDEO_PATH; + } else if (key.equals(downloadPathAudioPreference)) { + request = REQUEST_DOWNLOAD_AUDIO_PATH; + } else { + return super.onPreferenceTreeClick(preference); + } + + Intent i; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && NewPipeSettings.useStorageAccessFramework(ctx)) { + i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + i = new Intent(getActivity(), FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_DIR); + } + + startActivityForResult(i, request); + + return true; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + assureCorrectAppLanguage(getContext()); + super.onActivityResult(requestCode, resultCode, data); + if (DEBUG) { + Log.d(TAG, "onActivityResult() called with: " + + "requestCode = [" + requestCode + "], " + + "resultCode = [" + resultCode + "], data = [" + data + "]" + ); + } + + if (resultCode != Activity.RESULT_OK) { + return; + } + + String key; + if (requestCode == REQUEST_DOWNLOAD_VIDEO_PATH) { + key = downloadPathVideoPreference; + } else if (requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) { + key = downloadPathAudioPreference; + } else { + return; + } + + Uri uri = data.getData(); + if (uri == null) { + showMessageDialog(R.string.general_error, R.string.invalid_directory); + return; + } + + + // revoke permissions on the old save path (required for SAF only) + final Context context = getContext(); + if (context == null) { + throw new NullPointerException("getContext()"); + } + + forgetSAFTree(context, defaultPreferences.getString(key, "")); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && !FilePickerActivityHelper.isOwnFileUri(context, uri)) { + // steps to acquire the selected path: + // 1. acquire permissions on the new save path + // 2. save the new path, if step(2) was successful + try { + context.grantUriPermission(context.getPackageName(), uri, + StoredDirectoryHelper.PERMISSION_FLAGS); + + StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, null); + Log.i(TAG, "Acquiring tree success from " + uri.toString()); + + if (!mainStorage.canWrite()) { + throw new IOException("No write permissions on " + uri.toString()); + } + } catch (IOException err) { + Log.e(TAG, "Error acquiring tree from " + uri.toString(), err); + showMessageDialog(R.string.general_error, R.string.no_available_dir); + return; + } + } else { + File target = Utils.getFileForUri(uri); + if (!target.canWrite()) { + showMessageDialog(R.string.download_to_sdcard_error_title, + R.string.download_to_sdcard_error_message); + return; + } + uri = Uri.fromFile(target); + } + + defaultPreferences.edit().putString(key, uri.toString()).apply(); + updatePreferencesSummary(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/HistorySettingsFragment.java new file mode 100644 index 000000000..5f27c1f24 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/HistorySettingsFragment.java @@ -0,0 +1,164 @@ +package org.schabi.newpipelegacy.settings; + +import android.os.Bundle; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.Preference; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.local.history.HistoryRecordManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.InfoCache; + +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; + +public class HistorySettingsFragment extends BasePreferenceFragment { + private String cacheWipeKey; + private String viewsHistoryClearKey; + private String playbackStatesClearKey; + private String searchHistoryClearKey; + private HistoryRecordManager recordManager; + private CompositeDisposable disposables; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + cacheWipeKey = getString(R.string.metadata_cache_wipe_key); + viewsHistoryClearKey = getString(R.string.clear_views_history_key); + playbackStatesClearKey = getString(R.string.clear_playback_states_key); + searchHistoryClearKey = getString(R.string.clear_search_history_key); + recordManager = new HistoryRecordManager(getActivity()); + disposables = new CompositeDisposable(); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.history_settings); + } + + @Override + public boolean onPreferenceTreeClick(final Preference preference) { + if (preference.getKey().equals(cacheWipeKey)) { + InfoCache.getInstance().clearCache(); + Toast.makeText(preference.getContext(), R.string.metadata_cache_wipe_complete_notice, + Toast.LENGTH_SHORT).show(); + } + + if (preference.getKey().equals(viewsHistoryClearKey)) { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.delete_view_history_alert) + .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) + .setPositiveButton(R.string.delete, ((dialog, which) -> { + final Disposable onDeletePlaybackStates + = recordManager.deleteCompelteStreamStateHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> Toast.makeText(getActivity(), + R.string.watch_history_states_deleted, + Toast.LENGTH_SHORT).show(), + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete playback states", + R.string.general_error))); + + final Disposable onDelete = recordManager.deleteWholeStreamHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> Toast.makeText(getActivity(), + R.string.watch_history_deleted, + Toast.LENGTH_SHORT).show(), + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete view history", + R.string.general_error))); + + final Disposable onClearOrphans = recordManager.removeOrphanedRecords() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> { + }, + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete search history", + R.string.general_error))); + disposables.add(onDeletePlaybackStates); + disposables.add(onClearOrphans); + disposables.add(onDelete); + })) + .create() + .show(); + } + + if (preference.getKey().equals(playbackStatesClearKey)) { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.delete_playback_states_alert) + .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) + .setPositiveButton(R.string.delete, ((dialog, which) -> { + + final Disposable onDeletePlaybackStates + = recordManager.deleteCompelteStreamStateHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> Toast.makeText(getActivity(), + R.string.watch_history_states_deleted, + Toast.LENGTH_SHORT).show(), + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete playback states", + R.string.general_error))); + + disposables.add(onDeletePlaybackStates); + })) + .create() + .show(); + } + + if (preference.getKey().equals(searchHistoryClearKey)) { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.delete_search_history_alert) + .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) + .setPositiveButton(R.string.delete, ((dialog, which) -> { + final Disposable onDelete = recordManager.deleteCompleteSearchHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + howManyDeleted -> Toast.makeText(getActivity(), + R.string.search_history_deleted, + Toast.LENGTH_SHORT).show(), + throwable -> ErrorActivity.reportError(getContext(), + throwable, + SettingsActivity.class, null, + ErrorActivity.ErrorInfo.make( + UserAction.DELETE_FROM_HISTORY, + "none", + "Delete search history", + R.string.general_error))); + disposables.add(onDelete); + })) + .create() + .show(); + } + + return super.onPreferenceTreeClick(preference); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/MainSettingsFragment.java new file mode 100644 index 000000000..8e9315e56 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/MainSettingsFragment.java @@ -0,0 +1,30 @@ +package org.schabi.newpipelegacy.settings; + +import android.os.Bundle; + +import androidx.preference.Preference; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.CheckForNewAppVersionTask; +import org.schabi.newpipelegacy.R; + +public class MainSettingsFragment extends BasePreferenceFragment { + public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.main_settings); + + if (!CheckForNewAppVersionTask.isGithubApk()) { + final Preference update = findPreference(getString(R.string.update_pref_screen_key)); + getPreferenceScreen().removePreference(update); + + defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply(); + } + + if (!DEBUG) { + final Preference debug = findPreference(getString(R.string.debug_pref_screen_key)); + getPreferenceScreen().removePreference(debug); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipelegacy/settings/NewPipeSettings.java new file mode 100644 index 000000000..b58a4b4f2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/NewPipeSettings.java @@ -0,0 +1,91 @@ +package org.schabi.newpipelegacy.settings; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Environment; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipelegacy.R; + +import java.io.File; + +/* + * Created by k3b on 07.01.2016. + * + * Copyright (C) Christian Schabesberger 2015 + * NewPipeSettings.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 . + */ + +/** + * Helper class for global settings. + */ +public final class NewPipeSettings { + private NewPipeSettings() { } + + public static void initSettings(final Context context) { + PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.content_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.download_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.video_audio_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); + + getVideoDownloadFolder(context); + getAudioDownloadFolder(context); + } + + private static void getVideoDownloadFolder(final Context context) { + getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); + } + + private static void getAudioDownloadFolder(final Context context) { + getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); + } + + private static void getDir(final Context context, final int keyID, + final String defaultDirectoryName) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String key = context.getString(keyID); + String downloadPath = prefs.getString(key, null); + if ((downloadPath != null) && (!downloadPath.isEmpty())) { + return; + } + + SharedPreferences.Editor spEditor = prefs.edit(); + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); + spEditor.apply(); + } + + @NonNull + public static File getDir(final String defaultDirectoryName) { + return new File(Environment.getExternalStorageDirectory(), defaultDirectoryName); + } + + private static String getNewPipeChildFolderPathForDir(final File dir) { + return new File(dir, "NewPipe").toURI().toString(); + } + + public static boolean useStorageAccessFramework(final Context context) { + final String key = context.getString(R.string.storage_use_saf); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + return prefs.getBoolean(key, false); + } + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/PeertubeInstanceListFragment.java new file mode 100644 index 000000000..bf9508d8b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/PeertubeInstanceListFragment.java @@ -0,0 +1,448 @@ +package org.schabi.newpipelegacy.settings; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.text.InputType; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.RadioButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import org.schabi.newpipelegacy.util.Constants; +import org.schabi.newpipelegacy.util.PeertubeHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class PeertubeInstanceListFragment extends Fragment { + private static final int MENU_ITEM_RESTORE_ID = 123456; + + private List instanceList = new ArrayList<>(); + private PeertubeInstance selectedInstance; + private String savedInstanceListKey; + private InstanceListAdapter instanceListAdapter; + + private ProgressBar progressBar; + private SharedPreferences sharedPreferences; + + private CompositeDisposable disposables = new CompositeDisposable(); + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + savedInstanceListKey = getString(R.string.peertube_instance_list_key); + selectedInstance = PeertubeHelper.getCurrentInstance(); + updateInstanceList(); + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_instance_list, container, false); + } + + @Override + public void onViewCreated(@NonNull final View rootView, + @Nullable final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + + initViews(rootView); + } + + private void initViews(@NonNull final View rootView) { + TextView instanceHelpTV = rootView.findViewById(R.id.instanceHelpTV); + instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, + getString(R.string.peertube_instance_list_url))); + + initButton(rootView); + + RecyclerView listInstances = rootView.findViewById(R.id.instances); + listInstances.setLayoutManager(new LinearLayoutManager(requireContext())); + + ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(listInstances); + + instanceListAdapter = new InstanceListAdapter(requireContext(), itemTouchHelper); + listInstances.setAdapter(instanceListAdapter); + + progressBar = rootView.findViewById(R.id.loading_progress_bar); + } + + @Override + public void onResume() { + super.onResume(); + updateTitle(); + } + + @Override + public void onPause() { + super.onPause(); + saveChanges(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) { + disposables.clear(); + } + disposables = null; + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + final MenuItem restoreItem = menu + .add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, R.string.restore_defaults); + restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + final int restoreIcon = ThemeHelper + .resolveResourceIdFromAttr(requireContext(), R.attr.ic_restore_defaults); + restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), restoreIcon)); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == MENU_ITEM_RESTORE_ID) { + restoreDefaults(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void updateInstanceList() { + instanceList.clear(); + instanceList.addAll(PeertubeHelper.getInstanceList(requireContext())); + } + + private void selectInstance(final PeertubeInstance instance) { + selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()); + sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, true).apply(); + } + + private void updateTitle() { + if (getActivity() instanceof AppCompatActivity) { + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.peertube_instance_url_title); + } + } + } + + private void saveChanges() { + JsonStringWriter jsonWriter = JsonWriter.string().object().array("instances"); + for (PeertubeInstance instance : instanceList) { + jsonWriter.object(); + jsonWriter.value("name", instance.getName()); + jsonWriter.value("url", instance.getUrl()); + jsonWriter.end(); + } + String jsonToSave = jsonWriter.end().end().done(); + sharedPreferences.edit().putString(savedInstanceListKey, jsonToSave).apply(); + } + + private void restoreDefaults() { + new AlertDialog.Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) + .setTitle(R.string.restore_defaults) + .setMessage(R.string.restore_defaults_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.yes, (dialog, which) -> { + sharedPreferences.edit().remove(savedInstanceListKey).apply(); + selectInstance(PeertubeInstance.defaultInstance); + updateInstanceList(); + instanceListAdapter.notifyDataSetChanged(); + }) + .show(); + } + + private void initButton(final View rootView) { + final FloatingActionButton fab = rootView.findViewById(R.id.addInstanceButton); + fab.setOnClickListener(v -> { + showAddItemDialog(requireContext()); + }); + } + + private void showAddItemDialog(final Context c) { + final EditText urlET = new EditText(c); + urlET.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); + urlET.setHint(R.string.peertube_instance_add_help); + AlertDialog dialog = new AlertDialog.Builder(c) + .setTitle(R.string.peertube_instance_add_title) + .setIcon(R.drawable.place_holder_peertube) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.finish, (dialog1, which) -> { + String url = urlET.getText().toString(); + addInstance(url); + }) + .create(); + dialog.setView(urlET, 50, 0, 50, 0); + dialog.show(); + } + + private void addInstance(final String url) { + String cleanUrl = cleanUrl(url); + if (cleanUrl == null) { + return; + } + progressBar.setVisibility(View.VISIBLE); + Disposable disposable = Single.fromCallable(() -> { + PeertubeInstance instance = new PeertubeInstance(cleanUrl); + instance.fetchInstanceMetaData(); + return instance; + }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe((instance) -> { + progressBar.setVisibility(View.GONE); + add(instance); + }, e -> { + progressBar.setVisibility(View.GONE); + Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, + Toast.LENGTH_SHORT).show(); + }); + disposables.add(disposable); + } + + @Nullable + private String cleanUrl(final String url) { + String cleanUrl = url.trim(); + // if protocol not present, add https + if (!cleanUrl.startsWith("http")) { + cleanUrl = "https://" + cleanUrl; + } + // remove trailing slash + cleanUrl = cleanUrl.replaceAll("/$", ""); + // only allow https + if (!cleanUrl.startsWith("https://")) { + Toast.makeText(getActivity(), R.string.peertube_instance_add_https_only, + Toast.LENGTH_SHORT).show(); + return null; + } + // only allow if not already exists + for (PeertubeInstance instance : instanceList) { + if (instance.getUrl().equals(cleanUrl)) { + Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, + Toast.LENGTH_SHORT).show(); + return null; + } + } + return cleanUrl; + } + + private void add(final PeertubeInstance instance) { + instanceList.add(instance); + instanceListAdapter.notifyDataSetChanged(); + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, + ItemTouchHelper.START | ItemTouchHelper.END) { + @Override + public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + final int viewSize, + final int viewSizeOutOfBounds, + final int totalSize, + final long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(12, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(final RecyclerView recyclerView, + final RecyclerView.ViewHolder source, + final RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType() + || instanceListAdapter == null) { + return false; + } + + final int sourceIndex = source.getAdapterPosition(); + final int targetIndex = target.getAdapterPosition(); + instanceListAdapter.swapItems(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + int position = viewHolder.getAdapterPosition(); + // do not allow swiping the selected instance + if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { + instanceListAdapter.notifyItemChanged(position); + return; + } + instanceList.remove(position); + instanceListAdapter.notifyItemRemoved(position); + + if (instanceList.isEmpty()) { + instanceList.add(selectedInstance); + instanceListAdapter.notifyItemInserted(0); + } + } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // List Handling + //////////////////////////////////////////////////////////////////////////*/ + + private class InstanceListAdapter + extends RecyclerView.Adapter { + private final LayoutInflater inflater; + private ItemTouchHelper itemTouchHelper; + private RadioButton lastChecked; + + InstanceListAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { + this.itemTouchHelper = itemTouchHelper; + this.inflater = LayoutInflater.from(context); + } + + public void swapItems(final int fromPosition, final int toPosition) { + Collections.swap(instanceList, fromPosition, toPosition); + notifyItemMoved(fromPosition, toPosition); + } + + @NonNull + @Override + public InstanceListAdapter.TabViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int viewType) { + View view = inflater.inflate(R.layout.item_instance, parent, false); + return new InstanceListAdapter.TabViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull final InstanceListAdapter.TabViewHolder holder, + final int position) { + holder.bind(position, holder); + } + + @Override + public int getItemCount() { + return instanceList.size(); + } + + class TabViewHolder extends RecyclerView.ViewHolder { + private AppCompatImageView instanceIconView; + private TextView instanceNameView; + private TextView instanceUrlView; + private RadioButton instanceRB; + private ImageView handle; + + TabViewHolder(final View itemView) { + super(itemView); + + instanceIconView = itemView.findViewById(R.id.instanceIcon); + instanceNameView = itemView.findViewById(R.id.instanceName); + instanceUrlView = itemView.findViewById(R.id.instanceUrl); + instanceRB = itemView.findViewById(R.id.selectInstanceRB); + handle = itemView.findViewById(R.id.handle); + } + + @SuppressLint("ClickableViewAccessibility") + void bind(final int position, final TabViewHolder holder) { + handle.setOnTouchListener(getOnTouchListener(holder)); + + final PeertubeInstance instance = instanceList.get(position); + instanceNameView.setText(instance.getName()); + instanceUrlView.setText(instance.getUrl()); + instanceRB.setOnCheckedChangeListener(null); + if (selectedInstance.getUrl().equals(instance.getUrl())) { + if (lastChecked != null && lastChecked != instanceRB) { + lastChecked.setChecked(false); + } + instanceRB.setChecked(true); + lastChecked = instanceRB; + } + instanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + selectInstance(instance); + if (lastChecked != null && lastChecked != instanceRB) { + lastChecked.setChecked(false); + } + lastChecked = instanceRB; + } + }); + instanceIconView.setImageResource(R.drawable.place_holder_peertube); + } + + @SuppressLint("ClickableViewAccessibility") + private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { + return (view, motionEvent) -> { + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (itemTouchHelper != null && getItemCount() > 1) { + itemTouchHelper.startDrag(item); + return true; + } + } + return false; + }; + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectChannelFragment.java new file mode 100644 index 000000000..6fc0d56cf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectChannelFragment.java @@ -0,0 +1,238 @@ +package org.schabi.newpipelegacy.settings; + +import android.app.Activity; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.subscription.SubscriptionEntity; +import org.schabi.newpipelegacy.local.subscription.SubscriptionManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.List; +import java.util.Vector; + +import de.hdodenhof.circleimageview.CircleImageView; +import io.reactivex.Observer; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +/** + * Created by Christian Schabesberger on 26.09.17. + * SelectChannelFragment.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 SelectChannelFragment extends DialogFragment { + /** + * This contains the base display options for images. + */ + private static final DisplayImageOptions DISPLAY_IMAGE_OPTIONS + = new DisplayImageOptions.Builder().cacheInMemory(true).build(); + + private final ImageLoader imageLoader = ImageLoader.getInstance(); + + private OnSelectedListener onSelectedListener = null; + private OnCancelListener onCancelListener = null; + + private ProgressBar progressBar; + private TextView emptyView; + private RecyclerView recyclerView; + + private List subscriptions = new Vector<>(); + + public void setOnSelectedListener(final OnSelectedListener listener) { + onSelectedListener = listener; + } + + public void setOnCancelListener(final OnCancelListener listener) { + onCancelListener = listener; + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.select_channel_fragment, container, false); + recyclerView = v.findViewById(R.id.items_list); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + SelectChannelAdapter channelAdapter = new SelectChannelAdapter(); + recyclerView.setAdapter(channelAdapter); + + progressBar = v.findViewById(R.id.progressBar); + emptyView = v.findViewById(R.id.empty_state_view); + progressBar.setVisibility(View.VISIBLE); + recyclerView.setVisibility(View.GONE); + emptyView.setVisibility(View.GONE); + + + SubscriptionManager subscriptionManager = new SubscriptionManager(getContext()); + subscriptionManager.subscriptions().toObservable() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriptionObserver()); + + return v; + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCancel(final DialogInterface dialogInterface) { + super.onCancel(dialogInterface); + if (onCancelListener != null) { + onCancelListener.onCancel(); + } + } + + private void clickedItem(final int position) { + if (onSelectedListener != null) { + SubscriptionEntity entry = subscriptions.get(position); + onSelectedListener + .onChannelSelected(entry.getServiceId(), entry.getUrl(), entry.getName()); + } + dismiss(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Item handling + //////////////////////////////////////////////////////////////////////////*/ + + private void displayChannels(final List newSubscriptions) { + this.subscriptions = newSubscriptions; + progressBar.setVisibility(View.GONE); + if (newSubscriptions.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + return; + } + recyclerView.setVisibility(View.VISIBLE); + + } + + private Observer> getSubscriptionObserver() { + return new Observer>() { + @Override + public void onSubscribe(final Disposable d) { } + + @Override + public void onNext(final List newSubscriptions) { + displayChannels(newSubscriptions); + } + + @Override + public void onError(final Throwable exception) { + SelectChannelFragment.this.onError(exception); + } + + @Override + public void onComplete() { } + }; + } + + /*////////////////////////////////////////////////////////////////////////// + // Error + //////////////////////////////////////////////////////////////////////////*/ + + protected void onError(final Throwable e) { + final Activity activity = getActivity(); + ErrorActivity.reportError(activity, e, activity.getClass(), null, ErrorActivity.ErrorInfo + .make(UserAction.UI_ERROR, "none", "", R.string.app_ui_crash)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + //////////////////////////////////////////////////////////////////////////*/ + + public interface OnSelectedListener { + void onChannelSelected(int serviceId, String url, String name); + } + + public interface OnCancelListener { + void onCancel(); + } + + private class SelectChannelAdapter + extends RecyclerView.Adapter { + @Override + public SelectChannelItemHolder onCreateViewHolder(final ViewGroup parent, + final int viewType) { + View item = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.select_channel_item, parent, false); + return new SelectChannelItemHolder(item); + } + + @Override + public void onBindViewHolder(final SelectChannelItemHolder holder, final int position) { + SubscriptionEntity entry = subscriptions.get(position); + holder.titleView.setText(entry.getName()); + holder.view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + clickedItem(position); + } + }); + imageLoader.displayImage(entry.getAvatarUrl(), holder.thumbnailView, + DISPLAY_IMAGE_OPTIONS); + } + + @Override + public int getItemCount() { + return subscriptions.size(); + } + + public class SelectChannelItemHolder extends RecyclerView.ViewHolder { + public final View view; + final CircleImageView thumbnailView; + final TextView titleView; + SelectChannelItemHolder(final View v) { + super(v); + this.view = v; + thumbnailView = v.findViewById(R.id.itemThumbnailView); + titleView = v.findViewById(R.id.itemTitleView); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectKioskFragment.java new file mode 100644 index 000000000..ea83bf103 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectKioskFragment.java @@ -0,0 +1,194 @@ +package org.schabi.newpipelegacy.settings; + +import android.app.Activity; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.KioskTranslator; +import org.schabi.newpipelegacy.util.ServiceHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.List; +import java.util.Vector; + +/** + * Created by Christian Schabesberger on 09.10.17. + * SelectKioskFragment.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 SelectKioskFragment extends DialogFragment { + private RecyclerView recyclerView = null; + private SelectKioskAdapter selectKioskAdapter = null; + + private OnSelectedListener onSelectedListener = null; + private OnCancelListener onCancelListener = null; + + public void setOnSelectedListener(final OnSelectedListener listener) { + onSelectedListener = listener; + } + + public void setOnCancelListener(final OnCancelListener listener) { + onCancelListener = listener; + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.select_kiosk_fragment, container, false); + recyclerView = v.findViewById(R.id.items_list); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + try { + selectKioskAdapter = new SelectKioskAdapter(); + } catch (Exception e) { + onError(e); + } + recyclerView.setAdapter(selectKioskAdapter); + + return v; + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCancel(final DialogInterface dialogInterface) { + super.onCancel(dialogInterface); + if (onCancelListener != null) { + onCancelListener.onCancel(); + } + } + + private void clickedItem(final SelectKioskAdapter.Entry entry) { + if (onSelectedListener != null) { + onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName); + } + dismiss(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Error + //////////////////////////////////////////////////////////////////////////*/ + + protected void onError(final Throwable e) { + final Activity activity = getActivity(); + ErrorActivity.reportError(activity, e, activity.getClass(), null, ErrorActivity.ErrorInfo + .make(UserAction.UI_ERROR, "none", "", R.string.app_ui_crash)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + //////////////////////////////////////////////////////////////////////////*/ + + public interface OnSelectedListener { + void onKioskSelected(int serviceId, String kioskId, String kioskName); + } + + public interface OnCancelListener { + void onCancel(); + } + + private class SelectKioskAdapter + extends RecyclerView.Adapter { + private final List kioskList = new Vector<>(); + + SelectKioskAdapter() throws Exception { + for (StreamingService service : NewPipe.getServices()) { + for (String kioskId : service.getKioskList().getAvailableKiosks()) { + String name = String.format(getString(R.string.service_kiosk_string), + service.getServiceInfo().getName(), + KioskTranslator.getTranslatedKioskName(kioskId, getContext())); + kioskList.add(new Entry(ServiceHelper.getIcon(service.getServiceId()), + service.getServiceId(), kioskId, name)); + } + } + } + + public int getItemCount() { + return kioskList.size(); + } + + public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { + View item = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.select_kiosk_item, parent, false); + return new SelectKioskItemHolder(item); + } + + public void onBindViewHolder(final SelectKioskItemHolder holder, final int position) { + final Entry entry = kioskList.get(position); + holder.titleView.setText(entry.kioskName); + holder.thumbnailView + .setImageDrawable(AppCompatResources.getDrawable(requireContext(), entry.icon)); + holder.view.setOnClickListener(view -> clickedItem(entry)); + } + + class Entry { + final int icon; + final int serviceId; + final String kioskId; + final String kioskName; + + Entry(final int i, final int si, final String ki, final String kn) { + icon = i; + serviceId = si; + kioskId = ki; + kioskName = kn; + } + } + + public class SelectKioskItemHolder extends RecyclerView.ViewHolder { + public final View view; + final ImageView thumbnailView; + final TextView titleView; + + SelectKioskItemHolder(final View v) { + super(v); + this.view = v; + thumbnailView = v.findViewById(R.id.itemThumbnailView); + titleView = v.findViewById(R.id.itemTitleView); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectPlaylistFragment.java new file mode 100644 index 000000000..344e40987 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/SelectPlaylistFragment.java @@ -0,0 +1,225 @@ +package org.schabi.newpipelegacy.settings; + +import android.app.Activity; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.DialogFragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipelegacy.NewPipeDatabase; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.AppDatabase; +import org.schabi.newpipelegacy.database.LocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistLocalItem; +import org.schabi.newpipelegacy.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipelegacy.database.playlist.model.PlaylistRemoteEntity; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistManager; +import org.schabi.newpipelegacy.local.playlist.RemotePlaylistManager; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; + +import java.util.List; +import java.util.Vector; + +import io.reactivex.Flowable; +import io.reactivex.disposables.Disposable; + +public class SelectPlaylistFragment extends DialogFragment { + /** + * This contains the base display options for images. + */ + private static final DisplayImageOptions DISPLAY_IMAGE_OPTIONS + = new DisplayImageOptions.Builder().cacheInMemory(true).build(); + + private final ImageLoader imageLoader = ImageLoader.getInstance(); + + private OnSelectedListener onSelectedListener = null; + private OnCancelListener onCancelListener = null; + + private ProgressBar progressBar; + private TextView emptyView; + private RecyclerView recyclerView; + private Disposable playlistsSubscriber; + + private List playlists = new Vector<>(); + + public void setOnSelectedListener(final OnSelectedListener listener) { + onSelectedListener = listener; + } + + public void setOnCancelListener(final OnCancelListener listener) { + onCancelListener = listener; + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View v = + inflater.inflate(R.layout.select_playlist_fragment, container, false); + recyclerView = v.findViewById(R.id.items_list); + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter(); + recyclerView.setAdapter(playlistAdapter); + + progressBar = v.findViewById(R.id.progressBar); + emptyView = v.findViewById(R.id.empty_state_view); + progressBar.setVisibility(View.VISIBLE); + recyclerView.setVisibility(View.GONE); + emptyView.setVisibility(View.GONE); + + final AppDatabase database = NewPipeDatabase.getInstance(requireContext()); + final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database); + final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database); + + playlistsSubscriber = Flowable.combineLatest(localPlaylistManager.getPlaylists(), + remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge) + .subscribe(this::displayPlaylists, this::onError); + + return v; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (playlistsSubscriber != null) { + playlistsSubscriber.dispose(); + playlistsSubscriber = null; + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCancel(final DialogInterface dialogInterface) { + super.onCancel(dialogInterface); + if (onCancelListener != null) { + onCancelListener.onCancel(); + } + } + + private void clickedItem(final int position) { + if (onSelectedListener != null) { + final LocalItem selectedItem = playlists.get(position); + + if (selectedItem instanceof PlaylistMetadataEntry) { + final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + onSelectedListener + .onLocalPlaylistSelected(entry.uid, entry.name); + + } else if (selectedItem instanceof PlaylistRemoteEntity) { + final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); + onSelectedListener.onRemotePlaylistSelected( + entry.getServiceId(), entry.getUrl(), entry.getName()); + } + } + dismiss(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Item handling + //////////////////////////////////////////////////////////////////////////*/ + + private void displayPlaylists(final List newPlaylists) { + this.playlists = newPlaylists; + progressBar.setVisibility(View.GONE); + if (newPlaylists.isEmpty()) { + emptyView.setVisibility(View.VISIBLE); + return; + } + recyclerView.setVisibility(View.VISIBLE); + + } + + /*////////////////////////////////////////////////////////////////////////// + // Error + //////////////////////////////////////////////////////////////////////////*/ + + protected void onError(final Throwable e) { + final Activity activity = getActivity(); + ErrorActivity.reportError(activity, e, activity.getClass(), null, ErrorActivity.ErrorInfo + .make(UserAction.UI_ERROR, "none", "", R.string.app_ui_crash)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + //////////////////////////////////////////////////////////////////////////*/ + + public interface OnSelectedListener { + void onLocalPlaylistSelected(long id, String name); + void onRemotePlaylistSelected(int serviceId, String url, String name); + } + + public interface OnCancelListener { + void onCancel(); + } + + private class SelectPlaylistAdapter + extends RecyclerView.Adapter { + @Override + public SelectPlaylistItemHolder onCreateViewHolder(final ViewGroup parent, + final int viewType) { + final View item = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_playlist_mini_item, parent, false); + return new SelectPlaylistItemHolder(item); + } + + @Override + public void onBindViewHolder(final SelectPlaylistItemHolder holder, final int position) { + final PlaylistLocalItem selectedItem = playlists.get(position); + + if (selectedItem instanceof PlaylistMetadataEntry) { + final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); + + holder.titleView.setText(entry.name); + holder.view.setOnClickListener(view -> clickedItem(position)); + imageLoader.displayImage(entry.thumbnailUrl, holder.thumbnailView, + DISPLAY_IMAGE_OPTIONS); + + } else if (selectedItem instanceof PlaylistRemoteEntity) { + final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); + + holder.titleView.setText(entry.getName()); + holder.view.setOnClickListener(view -> clickedItem(position)); + imageLoader.displayImage(entry.getThumbnailUrl(), holder.thumbnailView, + DISPLAY_IMAGE_OPTIONS); + } + } + + @Override + public int getItemCount() { + return playlists.size(); + } + + public class SelectPlaylistItemHolder extends RecyclerView.ViewHolder { + public final View view; + final ImageView thumbnailView; + final TextView titleView; + + SelectPlaylistItemHolder(final View v) { + super(v); + this.view = v; + thumbnailView = v.findViewById(R.id.itemThumbnailView); + titleView = v.findViewById(R.id.itemTitleView); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipelegacy/settings/SettingsActivity.java new file mode 100644 index 000000000..4ac99f3ef --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/SettingsActivity.java @@ -0,0 +1,108 @@ +package org.schabi.newpipelegacy.settings; + +import android.content.Context; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; + +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.Fragment; +import androidx.preference.Preference; +import androidx.preference.PreferenceFragmentCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.AndroidTvUtils; +import org.schabi.newpipelegacy.util.ThemeHelper; +import org.schabi.newpipelegacy.views.FocusOverlayView; + +import static org.schabi.newpipelegacy.util.Localization.assureCorrectAppLanguage; + +/* + * Created by Christian Schabesberger on 31.08.15. + * + * Copyright (C) Christian Schabesberger 2015 + * SettingsActivity.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 SettingsActivity extends AppCompatActivity + implements BasePreferenceFragment.OnPreferenceStartFragmentCallback { + + public static void initSettings(final Context context) { + NewPipeSettings.initSettings(context); + } + + @Override + protected void onCreate(final Bundle savedInstanceBundle) { + setTheme(ThemeHelper.getSettingsThemeStyle(this)); + assureCorrectAppLanguage(this); + super.onCreate(savedInstanceBundle); + setContentView(R.layout.settings_layout); + + Toolbar toolbar = findViewById(R.id.toolbar); + setSupportActionBar(toolbar); + + if (savedInstanceBundle == null) { + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment_holder, new MainSettingsFragment()) + .commit(); + } + + if (AndroidTvUtils.isTv(this)) { + FocusOverlayView.setupFocusObserver(this); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + ActionBar actionBar = getSupportActionBar(); + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setDisplayShowTitleEnabled(true); + } + + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + int id = item.getItemId(); + if (id == android.R.id.home) { + if (getSupportFragmentManager().getBackStackEntryCount() == 0) { + finish(); + } else { + getSupportFragmentManager().popBackStack(); + } + } + + return super.onOptionsItemSelected(item); + } + + @Override + public boolean onPreferenceStartFragment(final PreferenceFragmentCompat caller, + final Preference preference) { + Fragment fragment = Fragment + .instantiate(this, preference.getFragment(), preference.getExtras()); + getSupportFragmentManager().beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, + R.animator.custom_fade_in, R.animator.custom_fade_out) + .replace(R.id.fragment_holder, fragment) + .addToBackStack(null) + .commit(); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/UpdateSettingsFragment.java new file mode 100644 index 000000000..fe0b87d2b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/UpdateSettingsFragment.java @@ -0,0 +1,30 @@ +package org.schabi.newpipelegacy.settings; + +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.preference.Preference; + +import org.schabi.newpipelegacy.R; + +public class UpdateSettingsFragment extends BasePreferenceFragment { + private Preference.OnPreferenceChangeListener updatePreferenceChange + = (preference, newValue) -> { + defaultPreferences.edit() + .putBoolean(getString(R.string.update_app_key), (boolean) newValue).apply(); + return true; + }; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + String updateToggleKey = getString(R.string.update_app_key); + findPreference(updateToggleKey).setOnPreferenceChangeListener(updatePreferenceChange); + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.update_settings); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/VideoAudioSettingsFragment.java new file mode 100644 index 000000000..4159ac5c3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/VideoAudioSettingsFragment.java @@ -0,0 +1,126 @@ +package org.schabi.newpipelegacy.settings; + +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.os.Build; +import android.os.Bundle; +import android.provider.Settings; +import android.text.format.DateUtils; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.preference.ListPreference; + +import com.google.android.material.snackbar.Snackbar; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.PermissionHelper; + +import java.util.LinkedList; +import java.util.List; + +public class VideoAudioSettingsFragment extends BasePreferenceFragment { + private SharedPreferences.OnSharedPreferenceChangeListener listener; + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + updateSeekOptions(); + + listener = (sharedPreferences, s) -> { + + // on M and above, if user chooses to minimise to popup player on exit + // and the app doesn't have display over other apps permission, + // show a snackbar to let the user give permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + && s.equals(getString(R.string.minimize_on_exit_key))) { + String newSetting = sharedPreferences.getString(s, null); + if (newSetting != null + && newSetting.equals(getString(R.string.minimize_on_exit_popup_key)) + && !Settings.canDrawOverlays(getContext())) { + + Snackbar.make(getListView(), R.string.permission_display_over_apps, + Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.settings, view -> + PermissionHelper.checkSystemAlertWindowPermission(getContext())) + .show(); + + } + } else if (s.equals(getString(R.string.use_inexact_seek_key))) { + updateSeekOptions(); + } + }; + } + + /** + * Update fast-forward/-rewind seek duration options + * according to language and inexact seek setting. + * Exoplayer can't seek 5 seconds in audio when using inexact seek. + */ + private void updateSeekOptions() { + // initializing R.array.seek_duration_description to display the translation of seconds + final Resources res = getResources(); + final String[] durationsValues = res.getStringArray(R.array.seek_duration_value); + final List displayedDurationValues = new LinkedList<>(); + final List displayedDescriptionValues = new LinkedList<>(); + int currentDurationValue; + final boolean inexactSeek = getPreferenceManager().getSharedPreferences() + .getBoolean(res.getString(R.string.use_inexact_seek_key), false); + + for (String durationsValue : durationsValues) { + currentDurationValue = + Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS; + if (inexactSeek && currentDurationValue % 10 == 5) { + continue; + } + + displayedDurationValues.add(durationsValue); + try { + displayedDescriptionValues.add(String.format( + res.getQuantityString(R.plurals.seconds, + currentDurationValue), + currentDurationValue)); + } catch (Resources.NotFoundException ignored) { + // if this happens, the translation is missing, + // and the english string will be displayed instead + } + } + + final ListPreference durations = (ListPreference) findPreference( + getString(R.string.seek_duration_key)); + durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0])); + durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0])); + final int selectedDuration = Integer.parseInt(durations.getValue()); + if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) { + final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5; + durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS)); + + Toast toast = Toast + .makeText(getContext(), + getString(R.string.new_seek_duration_toast, newDuration), + Toast.LENGTH_LONG); + toast.show(); + } + } + + @Override + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.video_audio_settings); + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceManager().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(listener); + + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceManager().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(listener); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/custom/DurationListPreference.kt b/app/src/main/java/org/schabi/newpipelegacy/settings/custom/DurationListPreference.kt new file mode 100644 index 000000000..2978e7fa3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/custom/DurationListPreference.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipelegacy.settings.custom + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.ListPreference +import org.schabi.newpipelegacy.util.Localization + +/** + * An extension of a common ListPreference where it sets the duration values to human readable strings. + * + * The values in the entry values array will be interpreted as seconds. If the value of a specific position + * is less than or equals to zero, its original entry title will be used. + * + * If the entry values array have anything other than numbers in it, an exception will be raised. + */ +class DurationListPreference : ListPreference { + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?) : super(context) + + override fun onAttached() { + super.onAttached() + + val originalEntryTitles = entries + val originalEntryValues = entryValues + val newEntryTitles = arrayOfNulls(originalEntryValues.size) + + for (i in originalEntryValues.indices) { + val currentDurationValue: Int + try { + currentDurationValue = (originalEntryValues[i] as String).toInt() + } catch (e: NumberFormatException) { + throw RuntimeException("Invalid number was set in the preference entry values array", e) + } + + if (currentDurationValue <= 0) { + newEntryTitles[i] = originalEntryTitles[i] + } else { + newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue) + } + } + + entries = newEntryTitles + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/AddTabDialog.java b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/AddTabDialog.java new file mode 100644 index 000000000..057d02aef --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/AddTabDialog.java @@ -0,0 +1,98 @@ +package org.schabi.newpipelegacy.settings.tabs; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.TextView; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.appcompat.widget.AppCompatImageView; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.util.ThemeHelper; + +public final class AddTabDialog { + private final AlertDialog dialog; + + AddTabDialog(@NonNull final Context context, @NonNull final ChooseTabListItem[] items, + @NonNull final DialogInterface.OnClickListener actions) { + + dialog = new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.tab_choose)) + .setAdapter(new DialogListAdapter(context, items), actions) + .create(); + } + + public void show() { + dialog.show(); + } + + static final class ChooseTabListItem { + final int tabId; + final String itemName; + @DrawableRes + final int itemIcon; + + ChooseTabListItem(final Context context, final Tab tab) { + this(tab.getTabId(), tab.getTabName(context), tab.getTabIconRes(context)); + } + + ChooseTabListItem(final int tabId, final String itemName, + @DrawableRes final int itemIcon) { + this.tabId = tabId; + this.itemName = itemName; + this.itemIcon = itemIcon; + } + } + + private static final class DialogListAdapter extends BaseAdapter { + private final LayoutInflater inflater; + private final ChooseTabListItem[] items; + + @DrawableRes + private final int fallbackIcon; + + private DialogListAdapter(final Context context, final ChooseTabListItem[] items) { + this.inflater = LayoutInflater.from(context); + this.items = items; + this.fallbackIcon = ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_kiosk_hot); + } + + @Override + public int getCount() { + return items.length; + } + + @Override + public ChooseTabListItem getItem(final int position) { + return items[position]; + } + + @Override + public long getItemId(final int position) { + return getItem(position).tabId; + } + + @Override + public View getView(final int position, final View view, final ViewGroup parent) { + View convertView = view; + if (convertView == null) { + convertView = inflater.inflate(R.layout.list_choose_tabs_dialog, parent, false); + } + + final ChooseTabListItem item = getItem(position); + final AppCompatImageView tabIconView = convertView.findViewById(R.id.tabIcon); + final TextView tabNameView = convertView.findViewById(R.id.tabName); + + tabIconView.setImageResource(item.itemIcon > 0 ? item.itemIcon : fallbackIcon); + tabNameView.setText(item.itemName); + + return convertView; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/ChooseTabsFragment.java new file mode 100644 index 000000000..feb9e9f94 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/ChooseTabsFragment.java @@ -0,0 +1,449 @@ +package org.schabi.newpipelegacy.settings.tabs; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.settings.SelectChannelFragment; +import org.schabi.newpipelegacy.settings.SelectKioskFragment; +import org.schabi.newpipelegacy.settings.SelectPlaylistFragment; +import org.schabi.newpipelegacy.settings.tabs.AddTabDialog.ChooseTabListItem; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.schabi.newpipelegacy.settings.tabs.Tab.typeFrom; + +public class ChooseTabsFragment extends Fragment { + private static final int MENU_ITEM_RESTORE_ID = 123456; + + private TabsManager tabsManager; + + private final List tabList = new ArrayList<>(); + private ChooseTabsFragment.SelectedTabsAdapter selectedTabsAdapter; + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + tabsManager = TabsManager.getManager(requireContext()); + updateTabList(); + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_choose_tabs, container, false); + } + + @Override + public void onViewCreated(@NonNull final View rootView, + @Nullable final Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + + initButton(rootView); + + final RecyclerView listSelectedTabs = rootView.findViewById(R.id.selectedTabs); + listSelectedTabs.setLayoutManager(new LinearLayoutManager(requireContext())); + + final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(listSelectedTabs); + + selectedTabsAdapter = new SelectedTabsAdapter(requireContext(), itemTouchHelper); + listSelectedTabs.setAdapter(selectedTabsAdapter); + } + + @Override + public void onResume() { + super.onResume(); + updateTitle(); + } + + @Override + public void onPause() { + super.onPause(); + saveChanges(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, + R.string.restore_defaults); + restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + final int restoreIcon = ThemeHelper.resolveResourceIdFromAttr(requireContext(), + R.attr.ic_restore_defaults); + restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), restoreIcon)); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == MENU_ITEM_RESTORE_ID) { + restoreDefaults(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void updateTabList() { + tabList.clear(); + tabList.addAll(tabsManager.getTabs()); + } + + private void updateTitle() { + if (getActivity() instanceof AppCompatActivity) { + final ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(R.string.main_page_content); + } + } + } + + private void saveChanges() { + tabsManager.saveTabs(tabList); + } + + private void restoreDefaults() { + new AlertDialog.Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) + .setTitle(R.string.restore_defaults) + .setMessage(R.string.restore_defaults_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.yes, (dialog, which) -> { + tabsManager.resetTabs(); + updateTabList(); + selectedTabsAdapter.notifyDataSetChanged(); + }) + .show(); + } + + private void initButton(final View rootView) { + final FloatingActionButton fab = rootView.findViewById(R.id.addTabsButton); + fab.setOnClickListener(v -> { + final ChooseTabListItem[] availableTabs = getAvailableTabs(requireContext()); + + if (availableTabs.length == 0) { + //Toast.makeText(requireContext(), "No available tabs", Toast.LENGTH_SHORT).show(); + return; + } + + Dialog.OnClickListener actionListener = (dialog, which) -> { + final ChooseTabListItem selected = availableTabs[which]; + addTab(selected.tabId); + }; + + new AddTabDialog(requireContext(), availableTabs, actionListener) + .show(); + }); + } + + private void addTab(final Tab tab) { + tabList.add(tab); + selectedTabsAdapter.notifyDataSetChanged(); + } + + private void addTab(final int tabId) { + final Tab.Type type = typeFrom(tabId); + + if (type == null) { + ErrorActivity.reportError(requireContext(), + new IllegalStateException("Tab id not found: " + tabId), null, null, + ErrorActivity.ErrorInfo.make(UserAction.SOMETHING_ELSE, "none", + "Choosing tabs on settings", 0)); + return; + } + + switch (type) { + case KIOSK: + SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); + selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> + addTab(new Tab.KioskTab(serviceId, kioskId))); + selectKioskFragment.show(requireFragmentManager(), "select_kiosk"); + return; + case CHANNEL: + SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); + selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> + addTab(new Tab.ChannelTab(serviceId, url, name))); + selectChannelFragment.show(requireFragmentManager(), "select_channel"); + return; + case PLAYLIST: + SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); + selectPlaylistFragment.setOnSelectedListener( + new SelectPlaylistFragment.OnSelectedListener() { + @Override + public void onLocalPlaylistSelected(final long id, final String name) { + addTab(new Tab.PlaylistTab(id, name)); + } + + @Override + public void onRemotePlaylistSelected( + final int serviceId, final String url, final String name) { + addTab(new Tab.PlaylistTab(serviceId, url, name)); + } + }); + selectPlaylistFragment.show(requireFragmentManager(), "select_playlist"); + return; + default: + addTab(type.getTab()); + break; + } + } + + private ChooseTabListItem[] getAvailableTabs(final Context context) { + final ArrayList returnList = new ArrayList<>(); + + for (Tab.Type type : Tab.Type.values()) { + final Tab tab = type.getTab(); + switch (type) { + case BLANK: + if (!tabList.contains(tab)) { + returnList.add(new ChooseTabListItem(tab.getTabId(), + getString(R.string.blank_page_summary), + tab.getTabIconRes(context))); + } + break; + case KIOSK: + returnList.add(new ChooseTabListItem(tab.getTabId(), + getString(R.string.kiosk_page_summary), + ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_kiosk_hot))); + break; + case CHANNEL: + returnList.add(new ChooseTabListItem(tab.getTabId(), + getString(R.string.channel_page_summary), + tab.getTabIconRes(context))); + break; + case DEFAULT_KIOSK: + if (!tabList.contains(tab)) { + returnList.add(new ChooseTabListItem(tab.getTabId(), + getString(R.string.default_kiosk_page_summary), + ThemeHelper.resolveResourceIdFromAttr(context, + R.attr.ic_kiosk_hot))); + } + break; + case PLAYLIST: + returnList.add(new ChooseTabListItem(tab.getTabId(), + getString(R.string.playlist_page_summary), + tab.getTabIconRes(context))); + break; + default: + if (!tabList.contains(tab)) { + returnList.add(new ChooseTabListItem(context, tab)); + } + break; + } + } + + return returnList.toArray(new ChooseTabListItem[0]); + } + + /*////////////////////////////////////////////////////////////////////////// + // List Handling + //////////////////////////////////////////////////////////////////////////*/ + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, + ItemTouchHelper.START | ItemTouchHelper.END) { + @Override + public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + final int viewSize, + final int viewSizeOutOfBounds, + final int totalSize, + final long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(12, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(final RecyclerView recyclerView, + final RecyclerView.ViewHolder source, + final RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType() + || selectedTabsAdapter == null) { + return false; + } + + final int sourceIndex = source.getAdapterPosition(); + final int targetIndex = target.getAdapterPosition(); + selectedTabsAdapter.swapItems(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + int position = viewHolder.getAdapterPosition(); + tabList.remove(position); + selectedTabsAdapter.notifyItemRemoved(position); + + if (tabList.isEmpty()) { + tabList.add(Tab.Type.BLANK.getTab()); + selectedTabsAdapter.notifyItemInserted(0); + } + } + }; + } + + private class SelectedTabsAdapter + extends RecyclerView.Adapter { + private final LayoutInflater inflater; + private ItemTouchHelper itemTouchHelper; + + SelectedTabsAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { + this.itemTouchHelper = itemTouchHelper; + this.inflater = LayoutInflater.from(context); + } + + public void swapItems(final int fromPosition, final int toPosition) { + Collections.swap(tabList, fromPosition, toPosition); + notifyItemMoved(fromPosition, toPosition); + } + + @NonNull + @Override + public ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder onCreateViewHolder( + @NonNull final ViewGroup parent, final int viewType) { + final View view = inflater.inflate(R.layout.list_choose_tabs, parent, false); + return new ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder(view); + } + + @Override + public void onBindViewHolder( + @NonNull final ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder holder, + final int position) { + holder.bind(position, holder); + } + + @Override + public int getItemCount() { + return tabList.size(); + } + + class TabViewHolder extends RecyclerView.ViewHolder { + private AppCompatImageView tabIconView; + private TextView tabNameView; + private ImageView handle; + + TabViewHolder(final View itemView) { + super(itemView); + + tabNameView = itemView.findViewById(R.id.tabName); + tabIconView = itemView.findViewById(R.id.tabIcon); + handle = itemView.findViewById(R.id.handle); + } + + @SuppressLint("ClickableViewAccessibility") + void bind(final int position, final TabViewHolder holder) { + handle.setOnTouchListener(getOnTouchListener(holder)); + + final Tab tab = tabList.get(position); + final Tab.Type type = Tab.typeFrom(tab.getTabId()); + + if (type == null) { + return; + } + + final String tabName; + switch (type) { + case BLANK: + tabName = getString(R.string.blank_page_summary); + break; + case DEFAULT_KIOSK: + tabName = getString(R.string.default_kiosk_page_summary); + break; + case KIOSK: + tabName = NewPipe.getNameOfService(((Tab.KioskTab) tab) + .getKioskServiceId()) + "/" + tab.getTabName(requireContext()); + break; + case CHANNEL: + tabName = NewPipe.getNameOfService(((Tab.ChannelTab) tab) + .getChannelServiceId()) + "/" + tab.getTabName(requireContext()); + break; + case PLAYLIST: + final int serviceId = ((Tab.PlaylistTab) tab).getPlaylistServiceId(); + final String serviceName = serviceId == -1 + ? getString(R.string.local) + : NewPipe.getNameOfService(serviceId); + tabName = serviceName + "/" + tab.getTabName(requireContext()); + break; + default: + tabName = tab.getTabName(requireContext()); + break; + } + + tabNameView.setText(tabName); + tabIconView.setImageResource(tab.getTabIconRes(requireContext())); + } + + @SuppressLint("ClickableViewAccessibility") + private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { + return (view, motionEvent) -> { + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (itemTouchHelper != null && getItemCount() > 1) { + itemTouchHelper.startDrag(item); + return true; + } + } + return false; + }; + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/Tab.java new file mode 100644 index 000000000..b6ae461bd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/Tab.java @@ -0,0 +1,612 @@ +package org.schabi.newpipelegacy.settings.tabs; + +import android.content.Context; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.ObjectsCompat; +import androidx.fragment.app.Fragment; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonSink; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.database.LocalItem.LocalItemType; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipelegacy.fragments.BlankFragment; +import org.schabi.newpipelegacy.fragments.list.channel.ChannelFragment; +import org.schabi.newpipelegacy.fragments.list.kiosk.DefaultKioskFragment; +import org.schabi.newpipelegacy.fragments.list.kiosk.KioskFragment; +import org.schabi.newpipelegacy.fragments.list.playlist.PlaylistFragment; +import org.schabi.newpipelegacy.local.bookmark.BookmarkFragment; +import org.schabi.newpipelegacy.local.feed.FeedFragment; +import org.schabi.newpipelegacy.local.history.StatisticsPlaylistFragment; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistFragment; +import org.schabi.newpipelegacy.local.subscription.SubscriptionFragment; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.KioskTranslator; +import org.schabi.newpipelegacy.util.ServiceHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.util.Objects; + +public abstract class Tab { + private static final String JSON_TAB_ID_KEY = "tab_id"; + + Tab() { + } + + Tab(@NonNull final JsonObject jsonObject) { + readDataFromJson(jsonObject); + } + + /*////////////////////////////////////////////////////////////////////////// + // Tab Handling + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + public static Tab from(@NonNull final JsonObject jsonObject) { + final int tabId = jsonObject.getInt(Tab.JSON_TAB_ID_KEY, -1); + + if (tabId == -1) { + return null; + } + + return from(tabId, jsonObject); + } + + @Nullable + public static Tab from(final int tabId) { + return from(tabId, null); + } + + @Nullable + public static Type typeFrom(final int tabId) { + for (Type available : Type.values()) { + if (available.getTabId() == tabId) { + return available; + } + } + return null; + } + + @Nullable + private static Tab from(final int tabId, @Nullable final JsonObject jsonObject) { + final Type type = typeFrom(tabId); + + if (type == null) { + return null; + } + + if (jsonObject != null) { + switch (type) { + case KIOSK: + return new KioskTab(jsonObject); + case CHANNEL: + return new ChannelTab(jsonObject); + case PLAYLIST: + return new PlaylistTab(jsonObject); + } + } + + return type.getTab(); + } + + public abstract int getTabId(); + + public abstract String getTabName(Context context); + + @DrawableRes + public abstract int getTabIconRes(Context context); + + /** + * Return a instance of the fragment that this tab represent. + * + * @param context Android app context + * @return the fragment this tab represents + */ + public abstract Fragment getFragment(Context context) throws ExtractionException; + + @Override + public boolean equals(final Object obj) { + if (obj == this) { + return true; + } + + return obj instanceof Tab && obj.getClass().equals(this.getClass()) + && ((Tab) obj).getTabId() == this.getTabId(); + } + + /*////////////////////////////////////////////////////////////////////////// + // JSON Handling + //////////////////////////////////////////////////////////////////////////*/ + + public void writeJsonOn(final JsonSink jsonSink) { + jsonSink.object(); + + jsonSink.value(JSON_TAB_ID_KEY, getTabId()); + writeDataToJson(jsonSink); + + jsonSink.end(); + } + + protected void writeDataToJson(final JsonSink writerSink) { + // No-op + } + + protected void readDataFromJson(final JsonObject jsonObject) { + // No-op + } + + /*////////////////////////////////////////////////////////////////////////// + // Implementations + //////////////////////////////////////////////////////////////////////////*/ + + public enum Type { + BLANK(new BlankTab()), + DEFAULT_KIOSK(new DefaultKioskTab()), + SUBSCRIPTIONS(new SubscriptionsTab()), + FEED(new FeedTab()), + BOOKMARKS(new BookmarksTab()), + HISTORY(new HistoryTab()), + KIOSK(new KioskTab()), + CHANNEL(new ChannelTab()), + PLAYLIST(new PlaylistTab()); + + private Tab tab; + + Type(final Tab tab) { + this.tab = tab; + } + + public int getTabId() { + return tab.getTabId(); + } + + public Tab getTab() { + return tab; + } + } + + public static class BlankTab extends Tab { + public static final int ID = 0; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return "NewPipe"; //context.getString(R.string.blank_page_summary); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_blank_page); + } + + @Override + public BlankFragment getFragment(final Context context) { + return new BlankFragment(); + } + } + + public static class SubscriptionsTab extends Tab { + public static final int ID = 1; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return context.getString(R.string.tab_subscriptions); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_channel); + } + + @Override + public SubscriptionFragment getFragment(final Context context) { + return new SubscriptionFragment(); + } + + } + + public static class FeedTab extends Tab { + public static final int ID = 2; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return context.getString(R.string.fragment_feed_title); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_rss); + } + + @Override + public FeedFragment getFragment(final Context context) { + return new FeedFragment(); + } + } + + public static class BookmarksTab extends Tab { + public static final int ID = 3; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return context.getString(R.string.tab_bookmarks); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_bookmark); + } + + @Override + public BookmarkFragment getFragment(final Context context) { + return new BookmarkFragment(); + } + } + + public static class HistoryTab extends Tab { + public static final int ID = 4; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return context.getString(R.string.title_activity_history); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_history); + } + + @Override + public StatisticsPlaylistFragment getFragment(final Context context) { + return new StatisticsPlaylistFragment(); + } + } + + public static class KioskTab extends Tab { + public static final int ID = 5; + private static final String JSON_KIOSK_SERVICE_ID_KEY = "service_id"; + private static final String JSON_KIOSK_ID_KEY = "kiosk_id"; + private int kioskServiceId; + private String kioskId; + + private KioskTab() { + this(-1, ""); + } + + public KioskTab(final int kioskServiceId, final String kioskId) { + this.kioskServiceId = kioskServiceId; + this.kioskId = kioskId; + } + + public KioskTab(final JsonObject jsonObject) { + super(jsonObject); + } + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return KioskTranslator.getTranslatedKioskName(kioskId, context); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + final int kioskIcon = KioskTranslator.getKioskIcon(kioskId, context); + + if (kioskIcon <= 0) { + throw new IllegalStateException("Kiosk ID is not valid: \"" + kioskId + "\""); + } + + return kioskIcon; + } + + @Override + public KioskFragment getFragment(final Context context) throws ExtractionException { + return KioskFragment.getInstance(kioskServiceId, kioskId); + } + + @Override + protected void writeDataToJson(final JsonSink writerSink) { + writerSink.value(JSON_KIOSK_SERVICE_ID_KEY, kioskServiceId) + .value(JSON_KIOSK_ID_KEY, kioskId); + } + + @Override + protected void readDataFromJson(final JsonObject jsonObject) { + kioskServiceId = jsonObject.getInt(JSON_KIOSK_SERVICE_ID_KEY, -1); + kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, ""); + } + + @Override + public boolean equals(final Object obj) { + return super.equals(obj) && kioskServiceId == ((KioskTab) obj).kioskServiceId + && ObjectsCompat.equals(kioskId, ((KioskTab) obj).kioskId); + } + + public int getKioskServiceId() { + return kioskServiceId; + } + + public String getKioskId() { + return kioskId; + } + } + + public static class ChannelTab extends Tab { + public static final int ID = 6; + private static final String JSON_CHANNEL_SERVICE_ID_KEY = "channel_service_id"; + private static final String JSON_CHANNEL_URL_KEY = "channel_url"; + private static final String JSON_CHANNEL_NAME_KEY = "channel_name"; + private int channelServiceId; + private String channelUrl; + private String channelName; + + private ChannelTab() { + this(-1, "", ""); + } + + public ChannelTab(final int channelServiceId, final String channelUrl, + final String channelName) { + this.channelServiceId = channelServiceId; + this.channelUrl = channelUrl; + this.channelName = channelName; + } + + public ChannelTab(final JsonObject jsonObject) { + super(jsonObject); + } + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return channelName; + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_channel); + } + + @Override + public ChannelFragment getFragment(final Context context) { + return ChannelFragment.getInstance(channelServiceId, channelUrl, channelName); + } + + @Override + protected void writeDataToJson(final JsonSink writerSink) { + writerSink.value(JSON_CHANNEL_SERVICE_ID_KEY, channelServiceId) + .value(JSON_CHANNEL_URL_KEY, channelUrl) + .value(JSON_CHANNEL_NAME_KEY, channelName); + } + + @Override + protected void readDataFromJson(final JsonObject jsonObject) { + channelServiceId = jsonObject.getInt(JSON_CHANNEL_SERVICE_ID_KEY, -1); + channelUrl = jsonObject.getString(JSON_CHANNEL_URL_KEY, ""); + channelName = jsonObject.getString(JSON_CHANNEL_NAME_KEY, ""); + } + + @Override + public boolean equals(final Object obj) { + return super.equals(obj) && channelServiceId == ((ChannelTab) obj).channelServiceId + && ObjectsCompat.equals(channelUrl, ((ChannelTab) obj).channelUrl) + && ObjectsCompat.equals(channelName, ((ChannelTab) obj).channelName); + } + + public int getChannelServiceId() { + return channelServiceId; + } + + public String getChannelUrl() { + return channelUrl; + } + + public String getChannelName() { + return channelName; + } + } + + public static class DefaultKioskTab extends Tab { + public static final int ID = 7; + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return KioskTranslator.getTranslatedKioskName(getDefaultKioskId(context), context); + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return KioskTranslator.getKioskIcon(getDefaultKioskId(context), context); + } + + @Override + public DefaultKioskFragment getFragment(final Context context) { + return new DefaultKioskFragment(); + } + + private String getDefaultKioskId(final Context context) { + final int kioskServiceId = ServiceHelper.getSelectedServiceId(context); + + String kioskId = ""; + try { + final StreamingService service = NewPipe.getService(kioskServiceId); + kioskId = service.getKioskList().getDefaultKioskId(); + } catch (ExtractionException e) { + ErrorActivity.reportError(context, e, null, null, + ErrorActivity.ErrorInfo.make(UserAction.REQUESTED_KIOSK, "none", + "Loading default kiosk from selected service", 0)); + } + return kioskId; + } + } + + public static class PlaylistTab extends Tab { + public static final int ID = 8; + private static final String JSON_PLAYLIST_SERVICE_ID_KEY = "playlist_service_id"; + private static final String JSON_PLAYLIST_URL_KEY = "playlist_url"; + private static final String JSON_PLAYLIST_NAME_KEY = "playlist_name"; + private static final String JSON_PLAYLIST_ID_KEY = "playlist_id"; + private static final String JSON_PLAYLIST_TYPE_KEY = "playlist_type"; + private int playlistServiceId; + private String playlistUrl; + private String playlistName; + private long playlistId; + private LocalItemType playlistType; + + private PlaylistTab() { + this(-1, ""); + } + + public PlaylistTab(final long playlistId, final String playlistName) { + this.playlistName = playlistName; + this.playlistId = playlistId; + this.playlistType = LocalItemType.PLAYLIST_LOCAL_ITEM; + this.playlistServiceId = -1; + this.playlistUrl = ""; + } + + public PlaylistTab(final int playlistServiceId, final String playlistUrl, + final String playlistName) { + this.playlistServiceId = playlistServiceId; + this.playlistUrl = playlistUrl; + this.playlistName = playlistName; + this.playlistType = LocalItemType.PLAYLIST_REMOTE_ITEM; + this.playlistId = -1; + } + + public PlaylistTab(final JsonObject jsonObject) { + super(jsonObject); + } + + @Override + public int getTabId() { + return ID; + } + + @Override + public String getTabName(final Context context) { + return playlistName; + } + + @DrawableRes + @Override + public int getTabIconRes(final Context context) { + return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_bookmark); + } + + @Override + public Fragment getFragment(final Context context) { + if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { + return LocalPlaylistFragment.getInstance(playlistId, playlistName); + + } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM + return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); + } + } + + @Override + protected void writeDataToJson(final JsonSink writerSink) { + writerSink.value(JSON_PLAYLIST_SERVICE_ID_KEY, playlistServiceId) + .value(JSON_PLAYLIST_URL_KEY, playlistUrl) + .value(JSON_PLAYLIST_NAME_KEY, playlistName) + .value(JSON_PLAYLIST_ID_KEY, playlistId) + .value(JSON_PLAYLIST_TYPE_KEY, playlistType.toString()); + } + + @Override + protected void readDataFromJson(final JsonObject jsonObject) { + playlistServiceId = jsonObject.getInt(JSON_PLAYLIST_SERVICE_ID_KEY, -1); + playlistUrl = jsonObject.getString(JSON_PLAYLIST_URL_KEY, ""); + playlistName = jsonObject.getString(JSON_PLAYLIST_NAME_KEY, ""); + playlistId = jsonObject.getInt(JSON_PLAYLIST_ID_KEY, -1); + playlistType = LocalItemType.valueOf( + jsonObject.getString(JSON_PLAYLIST_TYPE_KEY, + LocalItemType.PLAYLIST_LOCAL_ITEM.toString()) + ); + } + + @Override + public boolean equals(final Object obj) { + if (!(super.equals(obj) + && Objects.equals(playlistType, ((PlaylistTab) obj).playlistType) + && Objects.equals(playlistName, ((PlaylistTab) obj).playlistName))) { + return false; // base objects are different + } + + return (playlistId == ((PlaylistTab) obj).playlistId) // local + || (playlistServiceId == ((PlaylistTab) obj).playlistServiceId // remote + && Objects.equals(playlistUrl, ((PlaylistTab) obj).playlistUrl)); + } + + public int getPlaylistServiceId() { + return playlistServiceId; + } + + public String getPlaylistUrl() { + return playlistUrl; + } + + public String getPlaylistName() { + return playlistName; + } + + public long getPlaylistId() { + return playlistId; + } + + public LocalItemType getPlaylistType() { + return playlistType; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsJsonHelper.java b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsJsonHelper.java new file mode 100644 index 000000000..5c356be24 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsJsonHelper.java @@ -0,0 +1,123 @@ +package org.schabi.newpipelegacy.settings.tabs; + +import androidx.annotation.Nullable; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Class to get a JSON representation of a list of tabs, and the other way around. + */ +public final class TabsJsonHelper { + private static final String JSON_TABS_ARRAY_KEY = "tabs"; + + private static final List FALLBACK_INITIAL_TABS_LIST = Collections.unmodifiableList( + Arrays.asList( + Tab.Type.DEFAULT_KIOSK.getTab(), + Tab.Type.SUBSCRIPTIONS.getTab(), + Tab.Type.BOOKMARKS.getTab())); + + private TabsJsonHelper() { } + + /** + * Try to reads the passed JSON and returns the list of tabs if no error were encountered. + *

+ * If the JSON is null or empty, or the list of tabs that it represents is empty, the + * {@link #getDefaultTabs fallback list} will be returned. + *

+ * Tabs with invalid ids (i.e. not in the {@link Tab.Type} enum) will be ignored. + * + * @param tabsJson a JSON string got from {@link #getJsonToSave(List)}. + * @return a list of {@link Tab tabs}. + * @throws InvalidJsonException if the JSON string is not valid + */ + public static List getTabsFromJson(@Nullable final String tabsJson) + throws InvalidJsonException { + if (tabsJson == null || tabsJson.isEmpty()) { + return getDefaultTabs(); + } + + final List returnTabs = new ArrayList<>(); + + final JsonObject outerJsonObject; + try { + outerJsonObject = JsonParser.object().from(tabsJson); + + if (!outerJsonObject.has(JSON_TABS_ARRAY_KEY)) { + throw new InvalidJsonException("JSON doesn't contain \"" + JSON_TABS_ARRAY_KEY + + "\" array"); + } + + final JsonArray tabsArray = outerJsonObject.getArray(JSON_TABS_ARRAY_KEY); + + for (Object o : tabsArray) { + if (!(o instanceof JsonObject)) { + continue; + } + + final Tab tab = Tab.from((JsonObject) o); + + if (tab != null) { + returnTabs.add(tab); + } + } + } catch (JsonParserException e) { + throw new InvalidJsonException(e); + } + + if (returnTabs.isEmpty()) { + return getDefaultTabs(); + } + + return returnTabs; + } + + /** + * Get a JSON representation from a list of tabs. + * + * @param tabList a list of {@link Tab tabs}. + * @return a JSON string representing the list of tabs + */ + public static String getJsonToSave(@Nullable final List tabList) { + final JsonStringWriter jsonWriter = JsonWriter.string(); + jsonWriter.object(); + + jsonWriter.array(JSON_TABS_ARRAY_KEY); + if (tabList != null) { + for (Tab tab : tabList) { + tab.writeJsonOn(jsonWriter); + } + } + jsonWriter.end(); + + jsonWriter.end(); + return jsonWriter.done(); + } + + public static List getDefaultTabs() { + return FALLBACK_INITIAL_TABS_LIST; + } + + public static final class InvalidJsonException extends Exception { + private InvalidJsonException() { + super(); + } + + private InvalidJsonException(final String message) { + super(message); + } + + private InvalidJsonException(final Throwable cause) { + super(cause); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsManager.java b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsManager.java new file mode 100644 index 000000000..f87cb1e44 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/settings/tabs/TabsManager.java @@ -0,0 +1,86 @@ +package org.schabi.newpipelegacy.settings.tabs; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.widget.Toast; + +import org.schabi.newpipelegacy.R; + +import java.util.List; + +public final class TabsManager { + private final SharedPreferences sharedPreferences; + private final String savedTabsKey; + private final Context context; + private SavedTabsChangeListener savedTabsChangeListener; + private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; + + private TabsManager(final Context context) { + this.context = context; + this.sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + this.savedTabsKey = context.getString(R.string.saved_tabs_key); + } + + public static TabsManager getManager(final Context context) { + return new TabsManager(context); + } + + public List getTabs() { + final String savedJson = sharedPreferences.getString(savedTabsKey, null); + try { + return TabsJsonHelper.getTabsFromJson(savedJson); + } catch (TabsJsonHelper.InvalidJsonException e) { + Toast.makeText(context, R.string.saved_tabs_invalid_json, Toast.LENGTH_SHORT).show(); + return getDefaultTabs(); + } + } + + public void saveTabs(final List tabList) { + final String jsonToSave = TabsJsonHelper.getJsonToSave(tabList); + sharedPreferences.edit().putString(savedTabsKey, jsonToSave).apply(); + } + + public void resetTabs() { + sharedPreferences.edit().remove(savedTabsKey).apply(); + } + + public List getDefaultTabs() { + return TabsJsonHelper.getDefaultTabs(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Listener + //////////////////////////////////////////////////////////////////////////*/ + + public void setSavedTabsListener(final SavedTabsChangeListener listener) { + if (preferenceChangeListener != null) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); + } + savedTabsChangeListener = listener; + preferenceChangeListener = getPreferenceChangeListener(); + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener); + } + + public void unsetSavedTabsListener() { + if (preferenceChangeListener != null) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); + } + preferenceChangeListener = null; + savedTabsChangeListener = null; + } + + private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() { + return (sp, key) -> { + if (key.equals(savedTabsKey)) { + if (savedTabsChangeListener != null) { + savedTabsChangeListener.onTabsChanged(); + } + } + }; + } + + public interface SavedTabsChangeListener { + void onTabsChanged(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/DataReader.java b/app/src/main/java/org/schabi/newpipelegacy/streams/DataReader.java new file mode 100644 index 000000000..40f9505df --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/DataReader.java @@ -0,0 +1,266 @@ +package org.schabi.newpipelegacy.streams; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +/** + * @author kapodamy + */ +public class DataReader { + public static final int SHORT_SIZE = 2; + public static final int LONG_SIZE = 8; + public static final int INTEGER_SIZE = 4; + public static final int FLOAT_SIZE = 4; + + private static final int BUFFER_SIZE = 128 * 1024; // 128 KiB + + private long position = 0; + private final SharpStream stream; + + private InputStream view; + private int viewSize; + + public DataReader(final SharpStream stream) { + this.stream = stream; + this.readOffset = this.readBuffer.length; + } + + public long position() { + return position; + } + + public int read() throws IOException { + if (fillBuffer()) { + return -1; + } + + position++; + readCount--; + + return readBuffer[readOffset++] & 0xFF; + } + + public long skipBytes(final long byteAmount) throws IOException { + long amount = byteAmount; + if (readCount < 0) { + return 0; + } else if (readCount == 0) { + amount = stream.skip(amount); + } else { + if (readCount > amount) { + readCount -= (int) amount; + readOffset += (int) amount; + } else { + amount = readCount + stream.skip(amount - readCount); + readCount = 0; + readOffset = readBuffer.length; + } + } + + position += amount; + return amount; + } + + public int readInt() throws IOException { + primitiveRead(INTEGER_SIZE); + return primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; + } + + public long readUnsignedInt() throws IOException { + long value = readInt(); + return value & 0xffffffffL; + } + + + public short readShort() throws IOException { + primitiveRead(SHORT_SIZE); + return (short) (primitive[0] << 8 | primitive[1]); + } + + public 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 int read(final byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + public int read(final byte[] buffer, final int off, final int c) throws IOException { + int offset = off; + int count = c; + + if (readCount < 0) { + return -1; + } + int total = 0; + + if (count >= readBuffer.length) { + if (readCount > 0) { + System.arraycopy(readBuffer, readOffset, buffer, offset, readCount); + readOffset += readCount; + + offset += readCount; + count -= readCount; + + total = readCount; + readCount = 0; + } + total += Math.max(stream.read(buffer, offset, count), 0); + } else { + while (count > 0 && !fillBuffer()) { + int read = Math.min(readCount, count); + System.arraycopy(readBuffer, readOffset, buffer, offset, read); + + readOffset += read; + readCount -= read; + + offset += read; + count -= read; + + total += read; + } + } + + position += total; + return total; + } + + public boolean available() { + return readCount > 0 || stream.available() > 0; + } + + public void rewind() throws IOException { + stream.rewind(); + + if ((position - viewSize) > 0) { + viewSize = 0; // drop view + } else { + viewSize += position; + } + + position = 0; + readOffset = readBuffer.length; + readCount = 0; + } + + public boolean canRewind() { + return stream.canRewind(); + } + + /** + * Wraps this instance of {@code DataReader} into {@code InputStream} + * object. Note: Any read in the {@code DataReader} will not modify + * (decrease) the view size + * + * @param size the size of the view + * @return the view + */ + public InputStream getView(final int size) { + if (view == null) { + view = new InputStream() { + @Override + public int read() throws IOException { + if (viewSize < 1) { + return -1; + } + int res = DataReader.this.read(); + if (res > 0) { + viewSize--; + } + return res; + } + + @Override + public int read(final byte[] buffer) throws IOException { + return read(buffer, 0, buffer.length); + } + + @Override + public int read(final byte[] buffer, final int offset, final int count) + throws IOException { + if (viewSize < 1) { + return -1; + } + + int res = DataReader.this.read(buffer, offset, Math.min(viewSize, count)); + viewSize -= res; + + return res; + } + + @Override + public long skip(final long amount) throws IOException { + if (viewSize < 1) { + return 0; + } + int res = (int) DataReader.this.skipBytes(Math.min(amount, viewSize)); + viewSize -= res; + + return res; + } + + @Override + public int available() { + return viewSize; + } + + @Override + public void close() { + viewSize = 0; + } + + @Override + public boolean markSupported() { + return false; + } + + }; + } + viewSize = size; + + return view; + } + + private final short[] primitive = new short[LONG_SIZE]; + + private void primitiveRead(final int amount) throws IOException { + byte[] buffer = new byte[amount]; + int read = read(buffer, 0, amount); + + if (read != amount) { + throw new EOFException("Truncated stream, missing " + + String.valueOf(amount - read) + " bytes"); + } + + for (int i = 0; i < amount; i++) { + // the "byte" data type in java is signed and is very annoying + primitive[i] = (short) (buffer[i] & 0xFF); + } + } + + private final byte[] readBuffer = new byte[BUFFER_SIZE]; + private int readOffset; + private int readCount; + + private boolean fillBuffer() throws IOException { + if (readCount < 0) { + return true; + } + if (readOffset >= readBuffer.length) { + readCount = stream.read(readBuffer); + if (readCount < 1) { + readCount = -1; + return true; + } + readOffset = 0; + } + + return readCount < 1; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4DashReader.java new file mode 100644 index 000000000..629e8d318 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4DashReader.java @@ -0,0 +1,947 @@ +package org.schabi.newpipelegacy.streams; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +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; + +/** + * @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_MDHD = 0x6D646864; + private static final int ATOM_EDTS = 0x65647473; + private static final int ATOM_ELST = 0x656C7374; + private static final int ATOM_HDLR = 0x68646C72; + private static final int ATOM_MINF = 0x6D696E66; + private static final int ATOM_DINF = 0x64696E66; + private static final int ATOM_STBL = 0x7374626C; + private static final int ATOM_STSD = 0x73747364; + private static final int ATOM_VMHD = 0x766D6864; + private static final int ATOM_SMHD = 0x736D6864; + + private static final int BRAND_DASH = 0x64617368; + private static final int BRAND_ISO5 = 0x69736F35; + + private static final int HANDLER_VIDE = 0x76696465; + private static final int HANDLER_SOUN = 0x736F756E; + private static final int HANDLER_SUBT = 0x73756274; + + private final DataReader stream; + + private Mp4Track[] tracks = null; + private int[] brands = null; + + private Box box; + private Moof moof; + + private boolean chunkZero = false; + + private int selectedTrack = -1; + private Box backupBox = null; + + public enum TrackKind { + Audio, Video, Subtitles, Other + } + + public Mp4DashReader(final SharpStream source) { + this.stream = new DataReader(source); + } + + public void parse() throws IOException, NoSuchElementException { + if (selectedTrack > -1) { + return; + } + + box = readBox(ATOM_FTYP); + brands = parseFtyp(box); + switch (brands[0]) { + case BRAND_DASH: + case BRAND_ISO5:// ¿why not? + break; + default: + throw new NoSuchElementException( + "Not a MPEG-4 DASH container, major brand is not 'dash' or 'iso5' is " + + boxName(brands[0]) + ); + } + + Moov moov = null; + int i; + + while (box.type != ATOM_MOOF) { + ensure(box); + box = readBox(); + + switch (box.type) { + case ATOM_MOOV: + moov = parseMoov(box); + break; + case ATOM_SIDX: + case ATOM_MFRA: + break; + } + } + + 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.mvexTrex != null) { + for (Trex mvexTrex : moov.mvexTrex) { + if (tracks[i].trak.tkhd.trackId == mvexTrex.trackId) { + tracks[i].trex = mvexTrex; + } + } + } + + switch (moov.trak[i].mdia.hdlr.subType) { + case HANDLER_VIDE: + tracks[i].kind = TrackKind.Video; + break; + case HANDLER_SOUN: + tracks[i].kind = TrackKind.Audio; + break; + case HANDLER_SUBT: + tracks[i].kind = TrackKind.Subtitles; + break; + default: + tracks[i].kind = TrackKind.Other; + break; + } + } + + backupBox = box; + } + + Mp4Track selectTrack(final int index) { + selectedTrack = index; + return tracks[index]; + } + + public int[] getBrands() { + if (brands == null) { + throw new IllegalStateException("Not parsed"); + } + return brands; + } + + public void rewind() throws IOException { + if (!stream.canRewind()) { + throw new IOException("The provided stream doesn't allow seek"); + } + if (box == null) { + return; + } + + box = backupBox; + chunkZero = false; + + stream.rewind(); + stream.skipBytes(backupBox.offset + (DataReader.INTEGER_SIZE * 2)); + } + + public Mp4Track[] getAvailableTracks() { + return tracks; + } + + public Mp4DashChunk getNextChunk(final boolean infoOnly) 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 = parseMoof(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 = (int) (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 + } + + Mp4DashChunk chunk = new Mp4DashChunk(); + chunk.moof = moof; + if (!infoOnly) { + chunk.data = stream.getView(moof.traf.trun.chunkSize); + } + + moof = null; + + stream.skipBytes(chunk.moof.traf.trun.dataOffset); + return chunk; + default: + } + } + + return null; + } + + public static boolean hasFlag(final int flags, final int mask) { + return (flags & mask) == mask; + } + + private String boxName(final Box ref) { + return boxName(ref.type); + } + + private String boxName(final 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.readUnsignedInt(); + b.type = stream.readInt(); + + if (b.size == 1) { + b.size = stream.readLong(); + } + + return b; + } + + private Box readBox(final int expected) throws IOException { + Box b = readBox(); + if (b.type != expected) { + throw new NoSuchElementException("expected " + boxName(expected) + + " found " + boxName(b)); + } + return b; + } + + private byte[] readFullBox(final Box ref) throws IOException { + // full box reading is limited to 2 GiB, and should be enough + int size = (int) ref.size; + + ByteBuffer buffer = ByteBuffer.allocate(size); + buffer.putInt(size); + buffer.putInt(ref.type); + + int read = size - 8; + + if (stream.read(buffer.array(), 8, read) != read) { + throw new EOFException(String.format("EOF reached in box: type=%s offset=%s size=%s", + boxName(ref.type), ref.offset, ref.size)); + } + + return buffer.array(); + } + + private void ensure(final 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(final Box ref, final 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 Box untilAnyBox(final Box ref) throws IOException { + if (stream.position() >= (ref.offset + ref.size)) { + return null; + } + + return readBox(); + } + + private Moof parseMoof(final Box ref, final int trackId) throws IOException { + Moof obj = new Moof(); + + Box b = readBox(ATOM_MFHD); + obj.mfhdSequenceNumber = parseMfhd(); + ensure(b); + + while ((b = untilBox(ref, ATOM_TRAF)) != null) { + obj.traf = parseTraf(b, trackId); + ensure(b); + + if (obj.traf != null) { + return obj; + } + } + + return obj; + } + + private int parseMfhd() throws IOException { + // version + // flags + stream.skipBytes(4); + + return stream.readInt(); + } + + private Traf parseTraf(final Box ref, final int trackId) throws IOException { + Traf traf = new Traf(); + + Box b = readBox(ATOM_TFHD); + traf.tfhd = parseTfhd(trackId); + ensure(b); + + if (traf.tfhd == null) { + return null; + } + + b = untilBox(ref, ATOM_TRUN, ATOM_TFDT); + + if (b.type == ATOM_TFDT) { + traf.tfdt = parseTfdt(); + ensure(b); + b = readBox(ATOM_TRUN); + } + + traf.trun = parseTrun(); + ensure(b); + + return traf; + } + + private Tfhd parseTfhd(final 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 parseTfdt() throws IOException { + int version = stream.read(); + stream.skipBytes(3); // flags + return version == 0 ? stream.readUnsignedInt() : stream.readLong(); + } + + private Trun parseTrun() throws IOException { + Trun obj = new Trun(); + obj.bFlags = stream.readInt(); + obj.entryCount = stream.readInt(); // unsigned int + + obj.entriesRowSize = 0; + if (hasFlag(obj.bFlags, 0x0100)) { + obj.entriesRowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0200)) { + obj.entriesRowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0400)) { + obj.entriesRowSize += 4; + } + if (hasFlag(obj.bFlags, 0x0800)) { + obj.entriesRowSize += 4; + } + obj.bEntries = new byte[obj.entriesRowSize * 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[] parseFtyp(final Box ref) throws IOException { + int i = 0; + int[] list = new int[(int) ((ref.offset + ref.size - stream.position() - 4) / 4)]; + + list[i++] = stream.readInt(); // major brand + + stream.skipBytes(4); // minor version + + for (; i < list.length; i++) { + list[i] = stream.readInt(); // compatible brands + } + + return list; + } + + private Mvhd parseMvhd() 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 = stream.readUnsignedInt(); + + // chunkDuration + stream.skipBytes(version == 0 ? 4 : 8); + + // rate + // volume + // reserved + // matrix array + // predefined + stream.skipBytes(76); + + obj.nextTrackId = stream.readUnsignedInt(); + + return obj; + } + + private Tkhd parseTkhd() 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 ? stream.readUnsignedInt() : 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 parseTrak(final Box ref) throws IOException { + Trak trak = new Trak(); + + Box b = readBox(ATOM_TKHD); + trak.tkhd = parseTkhd(); + ensure(b); + + while ((b = untilBox(ref, ATOM_MDIA, ATOM_EDTS)) != null) { + switch (b.type) { + case ATOM_MDIA: + trak.mdia = parseMdia(b); + break; + case ATOM_EDTS: + trak.edstElst = parseEdts(b); + break; + } + + ensure(b); + } + + return trak; + } + + private Mdia parseMdia(final Box ref) throws IOException { + Mdia obj = new Mdia(); + + Box b; + while ((b = untilBox(ref, ATOM_MDHD, ATOM_HDLR, ATOM_MINF)) != null) { + switch (b.type) { + case ATOM_MDHD: + obj.mdhd = readFullBox(b); + + // read time scale + ByteBuffer buffer = ByteBuffer.wrap(obj.mdhd); + byte version = buffer.get(8); + buffer.position(12 + ((version == 0 ? 4 : 8) * 2)); + obj.mdhdTimeScale = buffer.getInt(); + break; + case ATOM_HDLR: + obj.hdlr = parseHdlr(b); + break; + case ATOM_MINF: + obj.minf = parseMinf(b); + break; + } + ensure(b); + } + + return obj; + } + + private Hdlr parseHdlr(final Box ref) throws IOException { + // version + // flags + stream.skipBytes(4); + + Hdlr obj = new Hdlr(); + obj.bReserved = new byte[12]; + + obj.type = stream.readInt(); + obj.subType = stream.readInt(); + stream.read(obj.bReserved); + + // component name (is a ansi/ascii string) + stream.skipBytes((ref.offset + ref.size) - stream.position()); + + return obj; + } + + private Moov parseMoov(final Box ref) throws IOException { + Box b = readBox(ATOM_MVHD); + Moov moov = new Moov(); + moov.mvhd = parseMvhd(); + 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(parseTrak(b)); + break; + case ATOM_MVEX: + moov.mvexTrex = parseMvex(b, (int) moov.mvhd.nextTrackId); + break; + } + + ensure(b); + } + + moov.trak = tmp.toArray(new Trak[0]); + + return moov; + } + + private Trex[] parseMvex(final Box ref, final int possibleTrackCount) throws IOException { + ArrayList tmp = new ArrayList<>(possibleTrackCount); + + Box b; + while ((b = untilBox(ref, ATOM_TREX)) != null) { + tmp.add(parseTrex()); + ensure(b); + } + + return tmp.toArray(new Trex[0]); + } + + private Trex parseTrex() 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 Elst parseEdts(final Box ref) throws IOException { + Box b = untilBox(ref, ATOM_ELST); + if (b == null) { + return null; + } + + Elst obj = new Elst(); + + boolean v1 = stream.read() == 1; + stream.skipBytes(3); // flags + + int entryCount = stream.readInt(); + if (entryCount < 1) { + obj.bMediaRate = 0x00010000; // default media rate (1.0) + return obj; + } + + if (v1) { + stream.skipBytes(DataReader.LONG_SIZE); // segment duration + obj.mediaTime = stream.readLong(); + // ignore all remain entries + stream.skipBytes((entryCount - 1) * (DataReader.LONG_SIZE * 2)); + } else { + stream.skipBytes(DataReader.INTEGER_SIZE); // segment duration + obj.mediaTime = stream.readInt(); + } + + obj.bMediaRate = stream.readInt(); + + return obj; + } + + private Minf parseMinf(final Box ref) throws IOException { + Minf obj = new Minf(); + + Box b; + while ((b = untilAnyBox(ref)) != null) { + + switch (b.type) { + case ATOM_DINF: + obj.dinf = readFullBox(b); + break; + case ATOM_STBL: + obj.stblStsd = parseStbl(b); + break; + case ATOM_VMHD: + case ATOM_SMHD: + obj.mhd = readFullBox(b); + break; + + } + ensure(b); + } + + return obj; + } + + /** + * This only reads the "stsd" box inside. + * + * @param ref stbl box + * @return stsd box inside + */ + private byte[] parseStbl(final Box ref) throws IOException { + Box b = untilBox(ref, ATOM_STSD); + + if (b == null) { + return new byte[0]; // this never should happens (missing codec startup data) + } + + return readFullBox(b); + } + + class Box { + int type; + long offset; + long size; + } + + public class Moof { + int mfhdSequenceNumber; + 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; + } + + class TrunEntry { + int sampleDuration; + int sampleSize; + int sampleFlags; + int sampleCompositionTimeOffset; + + boolean hasCompositionTimeOffset; + boolean isKeyframe; + + } + + public class Trun { + public int chunkDuration; + public int chunkSize; + + public int bFlags; + int bFirstSampleFlags; + int dataOffset; + + public int entryCount; + byte[] bEntries; + int entriesRowSize; + + public TrunEntry getEntry(final int i) { + ByteBuffer buffer = ByteBuffer.wrap(bEntries, i * entriesRowSize, entriesRowSize); + 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(); + } + + entry.hasCompositionTimeOffset = hasFlag(bFlags, 0x0800); + entry.isKeyframe = !hasFlag(entry.sampleFlags, 0x10000); + + return entry; + } + + public TrunEntry getAbsoluteEntry(final int i, final Tfhd header) { + TrunEntry entry = getEntry(i); + + if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x20)) { + entry.sampleFlags = header.defaultSampleFlags; + } + + if (!hasFlag(bFlags, 0x0200) && hasFlag(header.bFlags, 0x10)) { + entry.sampleSize = header.defaultSampleSize; + } + + if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x08)) { + entry.sampleDuration = header.defaultSampleDuration; + } + + if (i == 0 && hasFlag(bFlags, 0x0004)) { + entry.sampleFlags = bFirstSampleFlags; + } + + 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 Elst edstElst; + public Mdia mdia; + + } + + class Mvhd { + long timeScale; + long nextTrackId; + } + + class Moov { + Mvhd mvhd; + Trak[] trak; + Trex[] mvexTrex; + } + + public class Trex { + private int trackId; + int defaultSampleDescriptionIndex; + int defaultSampleDuration; + int defaultSampleSize; + int defaultSampleFlags; + } + + public class Elst { + public long mediaTime; + public int bMediaRate; + } + + public class Mdia { + public int mdhdTimeScale; + public byte[] mdhd; + public Hdlr hdlr; + public Minf minf; + } + + public class Hdlr { + public int type; + public int subType; + public byte[] bReserved; + } + + public class Minf { + public byte[] dinf; + public byte[] stblStsd; + public byte[] mhd; + } + + public class Mp4Track { + public TrackKind kind; + public Trak trak; + public Trex trex; + } + + public class Mp4DashChunk { + public InputStream data; + public Moof moof; + private int i = 0; + + public TrunEntry getNextSampleInfo() { + if (i >= moof.traf.trun.entryCount) { + return null; + } + return moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); + } + + public Mp4DashSample getNextSample() throws IOException { + if (data == null) { + throw new IllegalStateException("This chunk has info only"); + } + if (i >= moof.traf.trun.entryCount) { + return null; + } + + Mp4DashSample sample = new Mp4DashSample(); + sample.info = moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); + sample.data = new byte[sample.info.sampleSize]; + + if (data.read(sample.data) != sample.info.sampleSize) { + throw new EOFException("EOF reached while reading a sample"); + } + + return sample; + } + } + + public class Mp4DashSample { + public TrunEntry info; + public byte[] data; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4FromDashWriter.java new file mode 100644 index 000000000..6164f0442 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/Mp4FromDashWriter.java @@ -0,0 +1,910 @@ +package org.schabi.newpipelegacy.streams; + +import org.schabi.newpipelegacy.streams.Mp4DashReader.Hdlr; +import org.schabi.newpipelegacy.streams.Mp4DashReader.Mdia; +import org.schabi.newpipelegacy.streams.Mp4DashReader.Mp4DashChunk; +import org.schabi.newpipelegacy.streams.Mp4DashReader.Mp4DashSample; +import org.schabi.newpipelegacy.streams.Mp4DashReader.Mp4Track; +import org.schabi.newpipelegacy.streams.Mp4DashReader.TrackKind; +import org.schabi.newpipelegacy.streams.Mp4DashReader.TrunEntry; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; + +/** + * @author kapodamy + */ +public class Mp4FromDashWriter { + private static final int EPOCH_OFFSET = 2082844800; + private static final short DEFAULT_TIMESCALE = 1000; + private static final byte SAMPLES_PER_CHUNK_INIT = 2; + // ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 + private static final byte SAMPLES_PER_CHUNK = 6; + // near 3.999 GiB + private static final long THRESHOLD_FOR_CO64 = 0xFFFEFFFFL; + // 2.2 MiB enough for: 1080p 60fps 00h35m00s + private static final int THRESHOLD_MOOV_LENGTH = (256 * 1024) + (2048 * 1024); + + private final long time; + + private ByteBuffer auxBuffer; + private SharpStream outStream; + + private long lastWriteOffset = -1; + private long writeOffset; + + private boolean moovSimulation = true; + + private boolean done = false; + private boolean parsed = false; + + private Mp4Track[] tracks; + private SharpStream[] sourceTracks; + + private Mp4DashReader[] readers; + private Mp4DashChunk[] readersChunks; + + private int overrideMainBrand = 0x00; + + private final ArrayList compatibleBrands = new ArrayList<>(5); + + public Mp4FromDashWriter(final SharpStream... sources) throws IOException { + for (SharpStream src : sources) { + if (!src.canRewind() && !src.canRead()) { + throw new IOException("All sources must be readable and allow rewind"); + } + } + + sourceTracks = sources; + readers = new Mp4DashReader[sourceTracks.length]; + readersChunks = new Mp4DashChunk[readers.length]; + time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; + + compatibleBrands.add(0x6D703431); // mp41 + compatibleBrands.add(0x69736F6D); // isom + compatibleBrands.add(0x69736F32); // iso2 + } + + public Mp4Track[] getTracksFromSource(final 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(final int... trackIndex) throws IOException { + if (done) { + throw new IOException("already done"); + } + if (tracks != null) { + throw new IOException("tracks already selected"); + } + + try { + tracks = new Mp4Track[readers.length]; + for (int i = 0; i < readers.length; i++) { + tracks[i] = readers[i].selectTrack(trackIndex[i]); + } + } finally { + parsed = true; + } + } + + public void setMainBrand(final int brand) { + overrideMainBrand = brand; + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public void close() throws IOException { + done = true; + parsed = true; + + for (SharpStream src : sourceTracks) { + src.close(); + } + + tracks = null; + sourceTracks = null; + + readers = null; + readersChunks = null; + + auxBuffer = null; + outStream = null; + } + + public void build(final SharpStream output) throws IOException { + if (done) { + throw new RuntimeException("already done"); + } + if (!output.canWrite()) { + throw new IOException("the provided output is not writable"); + } + + // + // WARNING: the muxer requires at least 8 samples of every track + // not allowed for very short tracks (less than 0.5 seconds) + // + outStream = output; + long read = 8; // mdat box header size + long totalSampleSize = 0; + int[] sampleExtra = new int[readers.length]; + int[] defaultMediaTime = new int[readers.length]; + int[] defaultSampleDuration = new int[readers.length]; + int[] sampleCount = new int[readers.length]; + + TablesInfo[] tablesInfo = new TablesInfo[tracks.length]; + for (int i = 0; i < tablesInfo.length; i++) { + tablesInfo[i] = new TablesInfo(); + } + + int singleSampleBuffer; + if (tracks.length == 1 && tracks[0].kind == TrackKind.Audio) { + // near 1 second of audio data per chunk, avoid split the audio stream in large chunks + singleSampleBuffer = tracks[0].trak.mdia.mdhdTimeScale / 1000; + } else { + singleSampleBuffer = -1; + } + + + for (int i = 0; i < readers.length; i++) { + int samplesSize = 0; + int sampleSizeChanges = 0; + int compositionOffsetLast = -1; + + Mp4DashChunk chunk; + while ((chunk = readers[i].getNextChunk(true)) != null) { + + if (defaultMediaTime[i] < 1 && chunk.moof.traf.tfhd.defaultSampleDuration > 0) { + defaultMediaTime[i] = chunk.moof.traf.tfhd.defaultSampleDuration; + } + + read += chunk.moof.traf.trun.chunkSize; + sampleExtra[i] += chunk.moof.traf.trun.chunkDuration; // calculate track duration + + TrunEntry info; + while ((info = chunk.getNextSampleInfo()) != null) { + if (info.isKeyframe) { + tablesInfo[i].stss++; + } + + if (info.sampleDuration > defaultSampleDuration[i]) { + defaultSampleDuration[i] = info.sampleDuration; + } + + tablesInfo[i].stsz++; + if (samplesSize != info.sampleSize) { + samplesSize = info.sampleSize; + sampleSizeChanges++; + } + + if (info.hasCompositionTimeOffset) { + if (info.sampleCompositionTimeOffset != compositionOffsetLast) { + tablesInfo[i].ctts++; + compositionOffsetLast = info.sampleCompositionTimeOffset; + } + } + + totalSampleSize += info.sampleSize; + } + } + + if (defaultMediaTime[i] < 1) { + defaultMediaTime[i] = defaultSampleDuration[i]; + } + + readers[i].rewind(); + + if (singleSampleBuffer > 0) { + initChunkTables(tablesInfo[i], singleSampleBuffer, singleSampleBuffer); + } else { + initChunkTables(tablesInfo[i], SAMPLES_PER_CHUNK_INIT, SAMPLES_PER_CHUNK); + } + + sampleCount[i] = tablesInfo[i].stsz; + + if (sampleSizeChanges == 1) { + tablesInfo[i].stsz = 0; + tablesInfo[i].stszDefault = samplesSize; + } else { + tablesInfo[i].stszDefault = 0; + } + + if (tablesInfo[i].stss == tablesInfo[i].stsz) { + tablesInfo[i].stss = -1; // for audio tracks (all samples are keyframes) + } + + // ensure track duration + if (tracks[i].trak.tkhd.duration < 1) { + tracks[i].trak.tkhd.duration = sampleExtra[i]; // this never should happen + } + } + + + boolean is64 = read > THRESHOLD_FOR_CO64; + + // calculate the moov size + int auxSize = makeMoov(defaultMediaTime, tablesInfo, is64); + + if (auxSize < THRESHOLD_MOOV_LENGTH) { + auxBuffer = ByteBuffer.allocate(auxSize); // cache moov in the memory + } + + moovSimulation = false; + writeOffset = 0; + + final int ftypSize = makeFtyp(); + + // reserve moov space in the output stream + if (auxSize > 0) { + int length = auxSize; + byte[] buffer = new byte[64 * 1024]; // 64 KiB + while (length > 0) { + int count = Math.min(length, buffer.length); + outWrite(buffer, count); + length -= count; + } + } + + if (auxBuffer == null) { + outSeek(ftypSize); + } + + // tablesInfo contains row counts + // and after returning from makeMoov() will contain those table offsets + makeMoov(defaultMediaTime, tablesInfo, is64); + + // write tables: stts stsc sbgp + // reset for ctts table: sampleCount sampleExtra + for (int i = 0; i < readers.length; i++) { + writeEntryArray(tablesInfo[i].stts, 2, sampleCount[i], defaultSampleDuration[i]); + writeEntryArray(tablesInfo[i].stsc, tablesInfo[i].stscBEntries.length, + tablesInfo[i].stscBEntries); + tablesInfo[i].stscBEntries = null; + if (tablesInfo[i].ctts > 0) { + sampleCount[i] = 1; // the index is not base zero + sampleExtra[i] = -1; + } + if (tablesInfo[i].sbgp > 0) { + writeEntryArray(tablesInfo[i].sbgp, 1, sampleCount[i]); + } + } + + if (auxBuffer == null) { + outRestore(); + } + + outWrite(makeMdat(totalSampleSize, is64)); + + int[] sampleIndex = new int[readers.length]; + int[] sizes = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; + int[] sync = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; + + int written = readers.length; + while (written > 0) { + written = 0; + + for (int i = 0; i < readers.length; i++) { + if (sampleIndex[i] < 0) { + continue; // track is done + } + + long chunkOffset = writeOffset; + int syncCount = 0; + int limit; + if (singleSampleBuffer > 0) { + limit = singleSampleBuffer; + } else { + limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; + } + + int j = 0; + for (; j < limit; j++) { + Mp4DashSample sample = getNextSample(i); + + if (sample == null) { + if (tablesInfo[i].ctts > 0 && sampleExtra[i] >= 0) { + writeEntryArray(tablesInfo[i].ctts, 1, sampleCount[i], + sampleExtra[i]); // flush last entries + outRestore(); + } + sampleIndex[i] = -1; + break; + } + + sampleIndex[i]++; + + if (tablesInfo[i].ctts > 0) { + if (sample.info.sampleCompositionTimeOffset == sampleExtra[i]) { + sampleCount[i]++; + } else { + if (sampleExtra[i] >= 0) { + tablesInfo[i].ctts = writeEntryArray(tablesInfo[i].ctts, 2, + sampleCount[i], sampleExtra[i]); + outRestore(); + } + sampleCount[i] = 1; + sampleExtra[i] = sample.info.sampleCompositionTimeOffset; + } + } + + if (tablesInfo[i].stss > 0 && sample.info.isKeyframe) { + sync[syncCount++] = sampleIndex[i]; + } + + if (tablesInfo[i].stsz > 0) { + sizes[j] = sample.data.length; + } + + outWrite(sample.data, sample.data.length); + } + + if (j > 0) { + written++; + + if (tablesInfo[i].stsz > 0) { + tablesInfo[i].stsz = writeEntryArray(tablesInfo[i].stsz, j, sizes); + } + + if (syncCount > 0) { + tablesInfo[i].stss = writeEntryArray(tablesInfo[i].stss, syncCount, sync); + } + + if (tablesInfo[i].stco > 0) { + if (is64) { + tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); + } else { + tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, + (int) chunkOffset); + } + } + + outRestore(); + } + } + } + + if (auxBuffer != null) { + // dump moov + outSeek(ftypSize); + outStream.write(auxBuffer.array(), 0, auxBuffer.capacity()); + auxBuffer = null; + } + } + + private Mp4DashSample getNextSample(final int track) throws IOException { + if (readersChunks[track] == null) { + readersChunks[track] = readers[track].getNextChunk(false); + if (readersChunks[track] == null) { + return null; // EOF reached + } + } + + Mp4DashSample sample = readersChunks[track].getNextSample(); + if (sample == null) { + readersChunks[track] = null; + return getNextSample(track); + } else { + return sample; + } + } + + + private int writeEntry64(final int offset, final long value) throws IOException { + outBackup(); + + auxSeek(offset); + auxWrite(ByteBuffer.allocate(8).putLong(value).array()); + + return offset + 8; + } + + private int writeEntryArray(final int offset, final int count, final int... values) + throws IOException { + outBackup(); + + auxSeek(offset); + + int size = count * 4; + ByteBuffer buffer = ByteBuffer.allocate(size); + + for (int i = 0; i < count; i++) { + buffer.putInt(values[i]); + } + + auxWrite(buffer.array()); + + return offset + size; + } + + private void outBackup() { + if (auxBuffer == null && lastWriteOffset < 0) { + lastWriteOffset = writeOffset; + } + } + + /** + * Restore to the previous position before the first call to writeEntry64() + * or writeEntryArray() methods. + */ + private void outRestore() throws IOException { + if (lastWriteOffset > 0) { + outSeek(lastWriteOffset); + lastWriteOffset = -1; + } + } + + private void initChunkTables(final TablesInfo tables, final int firstCount, + final int successiveCount) { + // tables.stsz holds amount of samples of the track (total) + int totalSamples = (tables.stsz - firstCount); + float chunkAmount = totalSamples / (float) successiveCount; + int remainChunkOffset = (int) Math.ceil(chunkAmount); + boolean remain = remainChunkOffset != (int) chunkAmount; + int index = 0; + + tables.stsc = 1; + if (firstCount != successiveCount) { + tables.stsc++; + } + if (remain) { + tables.stsc++; + } + + // stsc_table_entry = [first_chunk, samples_per_chunk, sample_description_index] + tables.stscBEntries = new int[tables.stsc * 3]; + tables.stco = remainChunkOffset + 1; // total entrys in chunk offset box + + tables.stscBEntries[index++] = 1; + tables.stscBEntries[index++] = firstCount; + tables.stscBEntries[index++] = 1; + + if (firstCount != successiveCount) { + tables.stscBEntries[index++] = 2; + tables.stscBEntries[index++] = successiveCount; + tables.stscBEntries[index++] = 1; + } + + if (remain) { + tables.stscBEntries[index++] = remainChunkOffset + 1; + tables.stscBEntries[index++] = totalSamples % successiveCount; + tables.stscBEntries[index] = 1; + } + } + + private void outWrite(final byte[] buffer) throws IOException { + outWrite(buffer, buffer.length); + } + + private void outWrite(final byte[] buffer, final int count) throws IOException { + writeOffset += count; + outStream.write(buffer, 0, count); + } + + private void outSeek(final long offset) throws IOException { + if (outStream.canSeek()) { + outStream.seek(offset); + writeOffset = offset; + } else if (outStream.canRewind()) { + outStream.rewind(); + writeOffset = 0; + outSkip(offset); + } else { + throw new IOException("cannot seek or rewind the output stream"); + } + } + + private void outSkip(final long amount) throws IOException { + outStream.skip(amount); + writeOffset += amount; + } + + private int lengthFor(final int offset) throws IOException { + int size = auxOffset() - offset; + + if (moovSimulation) { + return size; + } + + auxSeek(offset); + auxWrite(size); + auxSkip(size - 4); + + return size; + } + + private int make(final int type, final int extra, final int columns, final int rows) + throws IOException { + final byte base = 16; + int size = columns * rows * 4; + int total = size + base; + int offset = auxOffset(); + + if (extra >= 0) { + total += 4; + } + + auxWrite(ByteBuffer.allocate(12) + .putInt(total) + .putInt(type) + .putInt(0x00)// default version & flags + .array() + ); + + if (extra >= 0) { + offset += 4; + auxWrite(extra); + } + + auxWrite(rows); + auxSkip(size); + + return offset + base; + } + + private void auxWrite(final int value) throws IOException { + auxWrite(ByteBuffer.allocate(4) + .putInt(value) + .array() + ); + } + + private void auxWrite(final byte[] buffer) throws IOException { + if (moovSimulation) { + writeOffset += buffer.length; + } else if (auxBuffer == null) { + outWrite(buffer, buffer.length); + } else { + auxBuffer.put(buffer); + } + } + + private void auxSeek(final int offset) throws IOException { + if (moovSimulation) { + writeOffset = offset; + } else if (auxBuffer == null) { + outSeek(offset); + } else { + auxBuffer.position(offset); + } + } + + private void auxSkip(final int amount) throws IOException { + if (moovSimulation) { + writeOffset += amount; + } else if (auxBuffer == null) { + outSkip(amount); + } else { + auxBuffer.position(auxBuffer.position() + amount); + } + } + + private int auxOffset() { + return auxBuffer == null ? (int) writeOffset : auxBuffer.position(); + } + + private int makeFtyp() throws IOException { + int size = 16 + (compatibleBrands.size() * 4); + if (overrideMainBrand != 0) { + size += 4; + } + + ByteBuffer buffer = ByteBuffer.allocate(size); + buffer.putInt(size); + buffer.putInt(0x66747970); // "ftyp" + + if (overrideMainBrand == 0) { + buffer.putInt(0x6D703432); // mayor brand "mp42" + buffer.putInt(512); // default minor version + } else { + buffer.putInt(overrideMainBrand); + buffer.putInt(0); + buffer.putInt(0x6D703432); // "mp42" compatible brand + } + + for (Integer brand : compatibleBrands) { + buffer.putInt(brand); // compatible brand + } + + outWrite(buffer.array()); + + return size; + } + + private byte[] makeMdat(final long refSize, final boolean is64) { + long size = refSize; + if (is64) { + size += 16; + } else { + size += 8; + } + + ByteBuffer buffer = ByteBuffer.allocate(is64 ? 16 : 8) + .putInt(is64 ? 0x01 : (int) size) + .putInt(0x6D646174); // mdat + + if (is64) { + buffer.putLong(size); + } + + return buffer.array(); + } + + private void makeMvhd(final long longestTrack) throws IOException { + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 + }); + auxWrite(ByteBuffer.allocate(28) + .putLong(time) + .putLong(time) + .putInt(DEFAULT_TIMESCALE) + .putLong(longestTrack) + .array() + ); + + auxWrite(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 + }); + auxWrite(new byte[24]); // predefined + auxWrite(ByteBuffer.allocate(4) + .putInt(tracks.length + 1) + .array() + ); + } + + private int makeMoov(final int[] defaultMediaTime, final TablesInfo[] tablesInfo, + final boolean is64) throws RuntimeException, IOException { + int start = auxOffset(); + + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 + }); + + long longestTrack = 0; + long[] durations = new long[tracks.length]; + + for (int i = 0; i < durations.length; i++) { + durations[i] = (long) Math.ceil( + ((double) tracks[i].trak.tkhd.duration / tracks[i].trak.mdia.mdhdTimeScale) + * DEFAULT_TIMESCALE); + + if (durations[i] > longestTrack) { + longestTrack = durations[i]; + } + } + + makeMvhd(longestTrack); + + for (int i = 0; i < tracks.length; i++) { + if (tracks[i].trak.tkhd.matrix.length != 36) { + throw + new RuntimeException("bad track matrix length (expected 36) in track n°" + i); + } + makeTrak(i, durations[i], defaultMediaTime[i], tablesInfo[i], is64); + } + + return lengthFor(start); + } + + private void makeTrak(final int index, final long duration, final int defaultMediaTime, + final TablesInfo tables, final boolean is64) throws IOException { + int start = auxOffset(); + + auxWrite(new byte[]{ + // trak header + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B, + // tkhd header + 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 + }); + + ByteBuffer buffer = ByteBuffer.allocate(48); + buffer.putLong(time); + buffer.putLong(time); + buffer.putInt(index + 1); + buffer.position(24); + buffer.putLong(duration); + buffer.position(40); + buffer.putShort(tracks[index].trak.tkhd.bLayer); + buffer.putShort(tracks[index].trak.tkhd.bAlternateGroup); + buffer.putShort(tracks[index].trak.tkhd.bVolume); + auxWrite(buffer.array()); + + auxWrite(tracks[index].trak.tkhd.matrix); + auxWrite(ByteBuffer.allocate(8) + .putInt(tracks[index].trak.tkhd.bWidth) + .putInt(tracks[index].trak.tkhd.bHeight) + .array() + ); + + auxWrite(new byte[]{ + 0x00, 0x00, 0x00, 0x24, 0x65, 0x64, 0x74, 0x73, // edts header + 0x00, 0x00, 0x00, 0x1C, 0x65, 0x6C, 0x73, 0x74, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // elst header + }); + + int bMediaRate; + int mediaTime; + + if (tracks[index].trak.edstElst == null) { + // is a audio track ¿is edst/elst optional for audio tracks? + mediaTime = 0x00; // ffmpeg set this value as zero, instead of defaultMediaTime + bMediaRate = 0x00010000; + } else { + mediaTime = (int) tracks[index].trak.edstElst.mediaTime; + bMediaRate = tracks[index].trak.edstElst.bMediaRate; + } + + auxWrite(ByteBuffer + .allocate(12) + .putInt((int) duration) + .putInt(mediaTime) + .putInt(bMediaRate) + .array() + ); + + makeMdia(tracks[index].trak.mdia, tables, is64, tracks[index].kind == TrackKind.Audio); + + lengthFor(start); + } + + private void makeMdia(final Mdia mdia, final TablesInfo tablesInfo, final boolean is64, + final boolean isAudio) throws IOException { + int startMdia = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x61}); // mdia + auxWrite(mdia.mdhd); + auxWrite(makeHdlr(mdia.hdlr)); + + int startMinf = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x69, 0x6E, 0x66}); // minf + auxWrite(mdia.minf.mhd); + auxWrite(mdia.minf.dinf); + + int startStbl = auxOffset(); + auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x73, 0x74, 0x62, 0x6C}); // stbl + auxWrite(mdia.minf.stblStsd); + + // + // In audio tracks the following tables is not required: ssts ctts + // And stsz can be empty if has a default sample size + // + if (moovSimulation) { + make(0x73747473, -1, 2, 1); // stts + if (tablesInfo.stss > 0) { + make(0x73747373, -1, 1, tablesInfo.stss); + } + if (tablesInfo.ctts > 0) { + make(0x63747473, -1, 2, tablesInfo.ctts); + } + make(0x73747363, -1, 3, tablesInfo.stsc); + make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); + make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); + } else { + tablesInfo.stts = make(0x73747473, -1, 2, 1); + if (tablesInfo.stss > 0) { + tablesInfo.stss = make(0x73747373, -1, 1, tablesInfo.stss); + } + if (tablesInfo.ctts > 0) { + tablesInfo.ctts = make(0x63747473, -1, 2, tablesInfo.ctts); + } + tablesInfo.stsc = make(0x73747363, -1, 3, tablesInfo.stsc); + tablesInfo.stsz = make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); + tablesInfo.stco = make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, + tablesInfo.stco); + } + + if (isAudio) { + auxWrite(makeSgpd()); + tablesInfo.sbgp = makeSbgp(); // during simulation the returned offset is ignored + } + + lengthFor(startStbl); + lengthFor(startMinf); + lengthFor(startMdia); + } + + private byte[] makeHdlr(final Hdlr hdlr) { + ByteBuffer buffer = ByteBuffer.wrap(new byte[]{ + 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, // hdlr + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00// null string character + }); + + buffer.position(12); + buffer.putInt(hdlr.type); + buffer.putInt(hdlr.subType); + buffer.put(hdlr.bReserved); // always is a zero array + + return buffer.array(); + } + + private int makeSbgp() throws IOException { + int offset = auxOffset(); + + auxWrite(new byte[] { + 0x00, 0x00, 0x00, 0x1C, // box size + 0x73, 0x62, 0x67, 0x70, // "sbpg" + 0x00, 0x00, 0x00, 0x00, // default box flags + 0x72, 0x6F, 0x6C, 0x6C, // group type "roll" + 0x00, 0x00, 0x00, 0x01, // group table size + 0x00, 0x00, 0x00, 0x00, // group[0] total samples (to be set later) + 0x00, 0x00, 0x00, 0x01 // group[0] description index + }); + + return offset + 0x14; + } + + private byte[] makeSgpd() { + /* + * Sample Group Description Box + * + * ¿whats does? + * the table inside of this box gives information about the + * characteristics of sample groups. The descriptive information is any other + * information needed to define or characterize the sample group. + * + * ¿is replicable this box? + * NO due lacks of documentation about this box but... + * most of m4a encoders and ffmpeg uses this box with dummy values (same values) + */ + + ByteBuffer buffer = ByteBuffer.wrap(new byte[] { + 0x00, 0x00, 0x00, 0x1A, // box size + 0x73, 0x67, 0x70, 0x64, // "sgpd" + 0x01, 0x00, 0x00, 0x00, // box flags (unknown flag sets) + 0x72, 0x6F, 0x6C, 0x6C, // ¿¿group type?? + 0x00, 0x00, 0x00, 0x02, // ¿¿?? + 0x00, 0x00, 0x00, 0x01, // ¿¿?? + (byte) 0xFF, (byte) 0xFF // ¿¿?? + }); + + return buffer.array(); + } + + class TablesInfo { + int stts; + int stsc; + int[] stscBEntries; + int ctts; + int stsz; + int stszDefault; + int stss; + int stco; + int sbgp; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipelegacy/streams/OggFromWebMWriter.java new file mode 100644 index 000000000..99d5355b7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/OggFromWebMWriter.java @@ -0,0 +1,416 @@ +package org.schabi.newpipelegacy.streams; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.streams.WebMReader.Cluster; +import org.schabi.newpipelegacy.streams.WebMReader.Segment; +import org.schabi.newpipelegacy.streams.WebMReader.SimpleBlock; +import org.schabi.newpipelegacy.streams.WebMReader.WebMTrack; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * @author kapodamy + */ +public class OggFromWebMWriter implements Closeable { + private static final byte FLAG_UNSET = 0x00; + //private static final byte FLAG_CONTINUED = 0x01; + private static final byte FLAG_FIRST = 0x02; + private static final byte FLAG_LAST = 0x04; + + private static final byte HEADER_CHECKSUM_OFFSET = 22; + private static final byte HEADER_SIZE = 27; + + private static final int TIME_SCALE_NS = 1000000000; + + private boolean done = false; + private boolean parsed = false; + + private SharpStream source; + private SharpStream output; + + private int sequenceCount = 0; + private final int streamId; + private byte packetFlag = FLAG_FIRST; + + private WebMReader webm = null; + private WebMTrack webmTrack = null; + private Segment webmSegment = null; + private Cluster webmCluster = null; + private SimpleBlock webmBlock = null; + + private long webmBlockLastTimecode = 0; + private long webmBlockNearDuration = 0; + + private short segmentTableSize = 0; + private final byte[] segmentTable = new byte[255]; + private long segmentTableNextTimestamp = TIME_SCALE_NS; + + private final int[] crc32Table = new int[256]; + + public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) { + if (!source.canRead() || !source.canRewind()) { + throw new IllegalArgumentException("source stream must be readable and allows seeking"); + } + if (!target.canWrite() || !target.canRewind()) { + throw new IllegalArgumentException("output stream must be writable and allows seeking"); + } + + this.source = source; + this.output = target; + + this.streamId = (int) System.currentTimeMillis(); + + populateCrc32Table(); + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public WebMTrack[] getTracksFromSource() throws IllegalStateException { + if (!parsed) { + throw new IllegalStateException("source must be parsed first"); + } + + return webm.getAvailableTracks(); + } + + public void parseSource() throws IOException, IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (parsed) { + throw new IllegalStateException("already parsed"); + } + + try { + webm = new WebMReader(source); + webm.parse(); + webmSegment = webm.getNextSegment(); + } finally { + parsed = true; + } + } + + public void selectTrack(final int trackIndex) throws IOException { + if (!parsed) { + throw new IllegalStateException("source must be parsed first"); + } + if (done) { + throw new IOException("already done"); + } + if (webmTrack != null) { + throw new IOException("tracks already selected"); + } + + switch (webm.getAvailableTracks()[trackIndex].kind) { + case Audio: + case Video: + break; + default: + throw new UnsupportedOperationException("the track must an audio or video stream"); + } + + try { + webmTrack = webm.selectTrack(trackIndex); + } finally { + parsed = true; + } + } + + @Override + public void close() throws IOException { + done = true; + parsed = true; + + webmTrack = null; + webm = null; + + if (!output.isClosed()) { + output.flush(); + } + + source.close(); + output.close(); + } + + public void build() throws IOException { + float resolution; + SimpleBlock bloq; + ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255)); + ByteBuffer page = ByteBuffer.allocate(64 * 1024); + + header.order(ByteOrder.LITTLE_ENDIAN); + + /* step 1: get the amount of frames per seconds */ + switch (webmTrack.kind) { + case Audio: + resolution = getSampleFrequencyFromTrack(webmTrack.bMetadata); + if (resolution == 0f) { + throw new RuntimeException("cannot get the audio sample rate"); + } + break; + case Video: + // WARNING: untested + if (webmTrack.defaultDuration == 0) { + throw new RuntimeException("missing default frame time"); + } + resolution = 1000f / ((float) webmTrack.defaultDuration + / webmSegment.info.timecodeScale); + break; + default: + throw new RuntimeException("not implemented"); + } + + /* step 2: create packet with code init data */ + if (webmTrack.codecPrivate != null) { + addPacketSegment(webmTrack.codecPrivate.length); + makePacketheader(0x00, header, webmTrack.codecPrivate); + write(header); + output.write(webmTrack.codecPrivate); + } + + /* step 3: create packet with metadata */ + byte[] buffer = makeMetadata(); + if (buffer != null) { + addPacketSegment(buffer.length); + makePacketheader(0x00, header, buffer); + write(header); + output.write(buffer); + } + + /* step 4: calculate amount of packets */ + while (webmSegment != null) { + bloq = getNextBlock(); + + if (bloq != null && addPacketSegment(bloq)) { + int pos = page.position(); + //noinspection ResultOfMethodCallIgnored + bloq.data.read(page.array(), pos, bloq.dataSize); + page.position(pos + bloq.dataSize); + continue; + } + + // calculate the current packet duration using the next block + double elapsedNs = webmTrack.codecDelay; + + if (bloq == null) { + packetFlag = FLAG_LAST; // note: if the flag is FLAG_CONTINUED, is changed + elapsedNs += webmBlockLastTimecode; + + if (webmTrack.defaultDuration > 0) { + elapsedNs += webmTrack.defaultDuration; + } else { + // hardcoded way, guess the sample duration + elapsedNs += webmBlockNearDuration; + } + } else { + elapsedNs += bloq.absoluteTimeCodeNs; + } + + // get the sample count in the page + elapsedNs = elapsedNs / TIME_SCALE_NS; + elapsedNs = Math.ceil(elapsedNs * resolution); + + // create header and calculate page checksum + int checksum = makePacketheader((long) elapsedNs, header, null); + checksum = calcCrc32(checksum, page.array(), page.position()); + + header.putInt(HEADER_CHECKSUM_OFFSET, checksum); + + // dump data + write(header); + write(page); + + webmBlock = bloq; + } + } + + private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer, + final byte[] immediatePage) { + short length = HEADER_SIZE; + + buffer.putInt(0x5367674f); // "OggS" binary string in little-endian + buffer.put((byte) 0x00); // version + buffer.put(packetFlag); // type + + buffer.putLong(granPos); // granulate position + + buffer.putInt(streamId); // bitstream serial number + buffer.putInt(sequenceCount++); // page sequence number + + buffer.putInt(0x00); // page checksum + + buffer.put((byte) segmentTableSize); // segment table + buffer.put(segmentTable, 0, segmentTableSize); // segment size + + length += segmentTableSize; + + clearSegmentTable(); // clear segment table for next header + + int checksumCrc32 = calcCrc32(0x00, buffer.array(), length); + + if (immediatePage != null) { + checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.length); + buffer.putInt(HEADER_CHECKSUM_OFFSET, checksumCrc32); + segmentTableNextTimestamp -= TIME_SCALE_NS; + } + + return checksumCrc32; + } + + @Nullable + private byte[] makeMetadata() { + if ("A_OPUS".equals(webmTrack.codecId)) { + return new byte[]{ + 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string + 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) + 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) + }; + } else if ("A_VORBIS".equals(webmTrack.codecId)) { + return new byte[]{ + 0x03, // ¿¿¿??? + 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string + 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) + 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) + }; + } + + // not implemented for the desired codec + return null; + } + + private void write(final ByteBuffer buffer) throws IOException { + output.write(buffer.array(), 0, buffer.position()); + buffer.position(0); + } + + @Nullable + private SimpleBlock getNextBlock() throws IOException { + SimpleBlock res; + + if (webmBlock != null) { + res = webmBlock; + webmBlock = null; + return res; + } + + if (webmSegment == null) { + webmSegment = webm.getNextSegment(); + if (webmSegment == null) { + return null; // no more blocks in the selected track + } + } + + if (webmCluster == null) { + webmCluster = webmSegment.getNextCluster(); + if (webmCluster == null) { + webmSegment = null; + return getNextBlock(); + } + } + + res = webmCluster.getNextSimpleBlock(); + if (res == null) { + webmCluster = null; + return getNextBlock(); + } + + webmBlockNearDuration = res.absoluteTimeCodeNs - webmBlockLastTimecode; + webmBlockLastTimecode = res.absoluteTimeCodeNs; + + return res; + } + + private float getSampleFrequencyFromTrack(final byte[] bMetadata) { + // hardcoded way + ByteBuffer buffer = ByteBuffer.wrap(bMetadata); + + while (buffer.remaining() >= 6) { + int id = buffer.getShort() & 0xFFFF; + if (id == 0x0000B584) { + return buffer.getFloat(); + } + } + + return 0f; + } + + private void clearSegmentTable() { + segmentTableNextTimestamp += TIME_SCALE_NS; + packetFlag = FLAG_UNSET; + segmentTableSize = 0; + } + + private boolean addPacketSegment(final SimpleBlock block) { + long timestamp = block.absoluteTimeCodeNs + webmTrack.codecDelay; + + if (timestamp >= segmentTableNextTimestamp) { + return false; + } + + return addPacketSegment(block.dataSize); + } + + private boolean addPacketSegment(final int size) { + if (size > 65025) { + throw new UnsupportedOperationException("page size cannot be larger than 65025"); + } + + int available = (segmentTable.length - segmentTableSize) * 255; + boolean extra = (size % 255) == 0; + + if (extra) { + // add a zero byte entry in the table + // required to indicate the sample size is multiple of 255 + available -= 255; + } + + // check if possible add the segment, without overflow the table + if (available < size) { + return false; // not enough space on the page + } + + for (int seg = size; seg > 0; seg -= 255) { + segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255); + } + + if (extra) { + segmentTable[segmentTableSize++] = 0x00; + } + + return true; + } + + private void populateCrc32Table() { + for (int i = 0; i < 0x100; i++) { + int crc = i << 24; + for (int j = 0; j < 8; j++) { + long b = crc >>> 31; + crc <<= 1; + crc ^= (int) (0x100000000L - b) & 0x04c11db7; + } + crc32Table[i] = crc; + } + } + + private int calcCrc32(final int initialCrc, final byte[] buffer, final int size) { + int crc = initialCrc; + for (int i = 0; i < size; i++) { + int reg = (crc >>> 24) & 0xff; + crc = (crc << 8) ^ crc32Table[reg ^ (buffer[i] & 0xff)]; + } + + return crc; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/SrtFromTtmlWriter.java b/app/src/main/java/org/schabi/newpipelegacy/streams/SrtFromTtmlWriter.java new file mode 100644 index 000000000..016ce6228 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/SrtFromTtmlWriter.java @@ -0,0 +1,101 @@ +package org.schabi.newpipelegacy.streams; + +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; +import org.jsoup.parser.Parser; +import org.jsoup.select.Elements; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * @author kapodamy + */ +public class SrtFromTtmlWriter { + private static final String NEW_LINE = "\r\n"; + + private SharpStream out; + private boolean ignoreEmptyFrames; + + private int frameIndex = 0; + + public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) { + this.out = out; + this.ignoreEmptyFrames = ignoreEmptyFrames; + } + + private static String getTimestamp(final Element frame, final String attr) { + return frame + .attr(attr) + .replace('.', ','); // SRT subtitles uses comma as decimal separator + } + + private void writeFrame(final String begin, final String end, final StringBuilder text) + throws IOException { + writeString(String.valueOf(frameIndex++)); + writeString(NEW_LINE); + writeString(begin); + writeString(" --> "); + writeString(end); + writeString(NEW_LINE); + writeString(text.toString()); + writeString(NEW_LINE); + writeString(NEW_LINE); + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private void writeString(final String text) throws IOException { + out.write(text.getBytes("utf-8")); + } + + public void build(final SharpStream ttml) throws IOException { + /* + * TTML 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 + */ + + // parse XML + byte[] buffer = new byte[(int) ttml.available()]; + ttml.read(buffer); + Document doc = Jsoup.parse(new ByteArrayInputStream(buffer), "UTF-8", "", + Parser.xmlParser()); + + StringBuilder text = new StringBuilder(128); + Elements paragraphList = doc.select("body > div > p"); + + // check if has frames + if (paragraphList.size() < 1) { + return; + } + + for (Element paragraph : paragraphList) { + text.setLength(0); + + for (Node children : paragraph.childNodes()) { + if (children instanceof TextNode) { + text.append(((TextNode) children).text()); + } else if (children instanceof Element + && ((Element) children).tagName().equalsIgnoreCase("br")) { + text.append(NEW_LINE); + } + } + + if (ignoreEmptyFrames && text.length() < 1) { + continue; + } + + String begin = getTimestamp(paragraph, "begin"); + String end = getTimestamp(paragraph, "end"); + + writeFrame(begin, end, text); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipelegacy/streams/WebMReader.java new file mode 100644 index 000000000..120de5804 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/WebMReader.java @@ -0,0 +1,538 @@ +package org.schabi.newpipelegacy.streams; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.NoSuchElementException; + +/** + * + * @author kapodamy + */ +public class WebMReader { + private static final int ID_EMBL = 0x0A45DFA3; + private static final int ID_EMBL_READ_VERSION = 0x02F7; + private static final int ID_EMBL_DOC_TYPE = 0x0282; + private static final int ID_EMBL_DOC_TYPE_READ_VERSION = 0x0285; + + private static final int ID_SEGMENT = 0x08538067; + + private static final int ID_INFO = 0x0549A966; + private static final int ID_TIMECODE_SCALE = 0x0AD7B1; + private static final int ID_DURATION = 0x489; + + private static final int ID_TRACKS = 0x0654AE6B; + private static final int ID_TRACK_ENTRY = 0x2E; + private static final int ID_TRACK_NUMBER = 0x57; + private static final int ID_TRACK_TYPE = 0x03; + private static final int ID_CODEC_ID = 0x06; + private static final int ID_CODEC_PRIVATE = 0x23A2; + private static final int ID_VIDEO = 0x60; + private static final int ID_AUDIO = 0x61; + private static final int ID_DEFAULT_DURATION = 0x3E383; + private static final int ID_FLAG_LACING = 0x1C; + private static final int ID_CODEC_DELAY = 0x16AA; + private static final int ID_SEEK_PRE_ROLL = 0x16BB; + + private static final int ID_CLUSTER = 0x0F43B675; + private static final int ID_TIMECODE = 0x67; + private static final int ID_SIMPLE_BLOCK = 0x23; + private static final int ID_BLOCK = 0x21; + private static final int ID_GROUP_BLOCK = 0x20; + + + 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(final 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(final 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); + // WARNING: track cannot be the same or have different index in new segments + Element elem = untilElement(null, ID_SEGMENT); + if (elem == null) { + done = true; + return null; + } + segment = readSegment(elem, 0, false); + + return segment; + } + + private long readNumber(final 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; + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private String readString(final Element parent) throws IOException { + return new String(readBlob(parent), "utf-8"); + } + + private byte[] readBlob(final 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(final 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(final Element ref, final int... expected) throws IOException { + Element elem; + while (ref == null ? stream.available() : (stream.position() < (ref.offset + ref.size))) { + elem = readElement(); + if (expected.length < 1) { + return elem; + } + for (int type : expected) { + if (elem.type == type) { + return elem; + } + } + + ensure(elem); + } + + return null; + } + + private String elementID(final long type) { + return "0x".concat(Long.toHexString(type)); + } + + private void ensure(final 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(final Element ref, final int minReadVersion, + final int minDocTypeVersion) throws IOException { + Element elem = untilElement(ref, ID_EMBL_READ_VERSION); + if (elem == null) { + return false; + } + if (readNumber(elem) > minReadVersion) { + return false; + } + + elem = untilElement(ref, ID_EMBL_DOC_TYPE); + if (elem == null) { + return false; + } + if (!readString(elem).equals("webm")) { + return false; + } + elem = untilElement(ref, ID_EMBL_DOC_TYPE_READ_VERSION); + + return elem != null && readNumber(elem) <= minDocTypeVersion; + } + + private Info readInfo(final Element ref) throws IOException { + Element elem; + Info info = new Info(); + + while ((elem = untilElement(ref, ID_TIMECODE_SCALE, ID_DURATION)) != null) { + switch (elem.type) { + case ID_TIMECODE_SCALE: + 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(final Element ref, final int trackLacingExpected, + final 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(final Element ref, final int lacingExpected) throws IOException { + ArrayList trackEntries = new ArrayList<>(2); + Element elemTrackEntry; + + while ((elemTrackEntry = untilElement(ref, ID_TRACK_ENTRY)) != null) { + WebMTrack entry = new WebMTrack(); + boolean drop = false; + Element elem; + while ((elem = untilElement(elemTrackEntry)) != null) { + switch (elem.type) { + case ID_TRACK_NUMBER: + entry.trackNumber = readNumber(elem); + break; + case ID_TRACK_TYPE: + entry.trackType = (int) readNumber(elem); + break; + case ID_CODEC_ID: + entry.codecId = readString(elem); + break; + case ID_CODEC_PRIVATE: + entry.codecPrivate = readBlob(elem); + break; + case ID_AUDIO: + case ID_VIDEO: + entry.bMetadata = readBlob(elem); + break; + case ID_DEFAULT_DURATION: + entry.defaultDuration = readNumber(elem); + break; + case ID_FLAG_LACING: + drop = readNumber(elem) != lacingExpected; + break; + case ID_CODEC_DELAY: + entry.codecDelay = readNumber(elem); + break; + case ID_SEEK_PRE_ROLL: + entry.seekPreRoll = readNumber(elem); + break; + default: + break; + } + ensure(elem); + } + if (!drop) { + trackEntries.add(entry); + } + ensure(elemTrackEntry); + } + + 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(final Element ref) throws IOException { + SimpleBlock obj = new SimpleBlock(ref); + obj.trackNumber = readEncodedNumber(); + obj.relativeTimeCode = stream.readShort(); + obj.flags = (byte) stream.read(); + obj.dataSize = (int) ((ref.offset + ref.size) - stream.position()); + obj.createdFromBlock = ref.type == ID_BLOCK; + + // NOTE: lacing is not implemented, and will be mixed with the stream data + if (obj.dataSize < 0) { + throw new IOException(String.format( + "Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)); + } + return obj; + } + + private Cluster readCluster(final 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 = -1; + public long codecDelay = -1; + public long seekPreRoll = -1; + } + + public class Segment { + Segment(final 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 InputStream data; + public boolean createdFromBlock; + + SimpleBlock(final Element ref) { + this.ref = ref; + } + + public long trackNumber; + public short relativeTimeCode; + public long absoluteTimeCodeNs; + public byte flags; + public int dataSize; + private final Element ref; + + public boolean isKeyframe() { + return (flags & 0x80) == 0x80; + } + } + + public class Cluster { + Element ref; + SimpleBlock currentSimpleBlock = null; + Element currentBlockGroup = null; + public long timecode; + + Cluster(final Element ref) { + this.ref = ref; + } + + boolean insideClusterBounds() { + return stream.position() >= (ref.offset + ref.size); + } + + public SimpleBlock getNextSimpleBlock() throws IOException { + if (insideClusterBounds()) { + return null; + } + + if (currentBlockGroup != null) { + ensure(currentBlockGroup); + currentBlockGroup = null; + currentSimpleBlock = null; + } else if (currentSimpleBlock != null) { + ensure(currentSimpleBlock.ref); + } + + while (!insideClusterBounds()) { + Element elem = untilElement(ref, ID_SIMPLE_BLOCK, ID_GROUP_BLOCK); + if (elem == null) { + return null; + } + + if (elem.type == ID_GROUP_BLOCK) { + currentBlockGroup = elem; + elem = untilElement(currentBlockGroup, ID_BLOCK); + + if (elem == null) { + ensure(currentBlockGroup); + currentBlockGroup = null; + continue; + } + } + + currentSimpleBlock = readSimpleBlock(elem); + if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { + currentSimpleBlock.data = stream.getView((int) currentSimpleBlock.dataSize); + + // calculate the timestamp in nanoseconds + currentSimpleBlock.absoluteTimeCodeNs = currentSimpleBlock.relativeTimeCode + + this.timecode; + currentSimpleBlock.absoluteTimeCodeNs *= segment.info.timecodeScale; + + return currentSimpleBlock; + } + + ensure(elem); + } + return null; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipelegacy/streams/WebMWriter.java new file mode 100644 index 000000000..3b72c2625 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/WebMWriter.java @@ -0,0 +1,767 @@ +package org.schabi.newpipelegacy.streams; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.WebMReader.Cluster; +import org.schabi.newpipelegacy.streams.WebMReader.Segment; +import org.schabi.newpipelegacy.streams.WebMReader.SimpleBlock; +import org.schabi.newpipelegacy.streams.WebMReader.WebMTrack; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.nio.ByteBuffer; +import java.util.ArrayList; + +/** + * @author kapodamy + */ +public class WebMWriter implements Closeable { + private static final int BUFFER_SIZE = 8 * 1024; + private static final int DEFAULT_TIMECODE_SCALE = 1000000; + private static final int INTERV = 100; // 100ms on 1000000us timecode scale + private static final int DEFAULT_CUES_EACH_MS = 5000; // 5000ms on 1000000us timecode scale + private static final byte CLUSTER_HEADER_SIZE = 8; + private static final int CUE_RESERVE_SIZE = 65535; + private static final byte MINIMUM_EBML_VOID_SIZE = 4; + + 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[] readersCluster; + + private ArrayList clustersOffsetsSizes; + + private byte[] outBuffer; + private ByteBuffer outByteBuffer; + + public WebMWriter(final SharpStream... source) { + sourceTracks = source; + readers = new WebMReader[sourceTracks.length]; + infoTracks = new WebMTrack[sourceTracks.length]; + outBuffer = new byte[BUFFER_SIZE]; + outByteBuffer = ByteBuffer.wrap(outBuffer); + clustersOffsetsSizes = new ArrayList<>(256); + } + + public WebMTrack[] getTracksFromSource(final 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(final int... trackIndex) throws IOException { + try { + readersSegment = new Segment[readers.length]; + readersCluster = new Cluster[readers.length]; + + for (int i = 0; i < readers.length; i++) { + infoTracks[i] = readers[i].selectTrack(trackIndex[i]); + readersSegment[i] = readers[i].getNextSegment(); + } + } finally { + parsed = true; + } + } + + public boolean isDone() { + return done; + } + + @Override + public void close() { + done = true; + parsed = true; + + for (SharpStream src : sourceTracks) { + src.close(); + } + + sourceTracks = null; + readers = null; + infoTracks = null; + readersSegment = null; + readersCluster = null; + outBuffer = null; + outByteBuffer = null; + clustersOffsetsSizes = null; + } + + public void build(final 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 segmentOffset = 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*/ 0x56, + 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) 0x8e, 0x2a, (byte) 0xd7, (byte) 0xb1 + }); + // the segment duration MUST NOT exceed 4 bytes + listBuffer.add(encode(DEFAULT_TIMECODE_SCALE, true)); + listBuffer.add(new byte[]{0x44, (byte) 0x89, (byte) 0x84, + 0x00, 0x00, 0x00, 0x00, // info.duration + }); + + /* tracks */ + listBuffer.addAll(makeTracks()); + + dump(listBuffer, out); + + // reserve space for Cues element + long cueOffset = written; + makeEbmlVoid(out, CUE_RESERVE_SIZE, true); + + int[] defaultSampleDuration = new int[infoTracks.length]; + long[] duration = new long[infoTracks.length]; + + for (int i = 0; i < infoTracks.length; i++) { + if (infoTracks[i].defaultDuration < 0) { + defaultSampleDuration[i] = -1; // not available + } else { + defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration + / (float) DEFAULT_TIMECODE_SCALE); + } + duration[i] = -1; + } + + // Select a track for the cue + int cuesForTrackId = selectTrackForCue(); + long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; + ArrayList keyFrames = new ArrayList<>(32); + + int firstClusterOffset = (int) written; + long currentClusterOffset = makeCluster(out, 0, 0, true); + + 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; + currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, + true); + } + + 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(segmentOffset, currentClusterOffset, written, + bloq.absoluteTimecode)); + } + } + + writeBlock(out, bloq, baseTimecode); + blockWritten++; + + if (defaultSampleDuration[i] < 0 && duration[i] >= 0) { + // if the sample duration in unknown, + // calculate using current_duration - previous_duration + defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]); + } + duration[i] = bloq.absoluteTimecode; + + if (limitTimecode < 0) { + limitTimecode = bloq.absoluteTimecode + INTERV; + continue; + } + + if (bloq.absoluteTimecode >= limitTimecode) { + if (limitTimecodeByTrackId != i) { + limitTimecode += INTERV - (bloq.absoluteTimecode - limitTimecode); + } + i++; + } + } + } + + makeCluster(out, -1, currentClusterOffset, false); + + long segmentSize = written - offsetSegmentSizeSet - 7; + + /* Segment size */ + seekTo(out, offsetSegmentSizeSet); + outByteBuffer.putLong(0, segmentSize); + out.write(outBuffer, 1, DataReader.LONG_SIZE - 1); + + /* Segment duration */ + long longestDuration = 0; + for (int i = 0; i < duration.length; i++) { + if (defaultSampleDuration[i] > 0) { + duration[i] += defaultSampleDuration[i]; + } + if (duration[i] > longestDuration) { + longestDuration = duration[i]; + } + } + seekTo(out, offsetInfoDurationSet); + outByteBuffer.putFloat(0, longestDuration); + dump(outBuffer, DataReader.FLOAT_SIZE, out); + + /* first Cluster offset */ + firstClusterOffset -= segmentOffset; + writeInt(out, offsetClusterSet, firstClusterOffset); + + seekTo(out, cueOffset); + + /* Cue */ + short cueSize = 0; + dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); // header size is 7 + + for (KeyFrame keyFrame : keyFrames) { + int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer); + + if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) { + break; // no space left + } + + cueSize += size; + dump(outBuffer, size, out); + } + + makeEbmlVoid(out, CUE_RESERVE_SIZE - cueSize - 7, false); + + seekTo(out, cueOffset + 5); + outByteBuffer.putShort(0, cueSize); + dump(outBuffer, DataReader.SHORT_SIZE, out); + + /* seek head, seek for cues element */ + writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset)); + + for (ClusterInfo cluster : clustersOffsetsSizes) { + writeInt(out, cluster.offset, cluster.size | 0x10000000); + } + } + + private Block getNextBlockFrom(final 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 (readersCluster[internalTrackId] == null) { + readersCluster[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); + if (readersCluster[internalTrackId] == null) { + readersSegment[internalTrackId] = null; + return getNextBlockFrom(internalTrackId); + } + } + + SimpleBlock res = readersCluster[internalTrackId].getNextSimpleBlock(); + if (res == null) { + readersCluster[internalTrackId] = null; + return new Block(); // fake block to indicate the end of the cluster + } + + Block bloq = new Block(); + bloq.data = res.data; + bloq.dataSize = res.dataSize; + bloq.trackNumber = internalTrackId; + bloq.flags = res.flags; + bloq.absoluteTimecode = res.absoluteTimeCodeNs / DEFAULT_TIMECODE_SCALE; + + return bloq; + } + + private void seekTo(final SharpStream stream, final long offset) throws IOException { + if (stream.canSeek()) { + stream.seek(offset); + } else { + if (offset > written) { + stream.skip(offset - written); + } else { + stream.rewind(); + stream.skip(offset); + } + } + + written = offset; + } + + private void writeInt(final SharpStream stream, final long offset, final int number) + throws IOException { + seekTo(stream, offset); + outByteBuffer.putInt(0, number); + dump(outBuffer, DataReader.INTEGER_SIZE, stream); + } + + private void writeBlock(final SharpStream stream, final Block bloq, final 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)); + + dump(listBuffer, stream); + + int read; + while ((read = bloq.data.read(outBuffer)) > 0) { + dump(outBuffer, read, stream); + } + } + + private long makeCluster(final SharpStream stream, final long timecode, final long offsetStart, + final boolean create) throws IOException { + ClusterInfo cluster; + long offset = offsetStart; + + if (offset > 0) { + // save the size of the previous cluster (maximum 256 MiB) + cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1); + cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE); + } + + offset = written; + + if (create) { + /* cluster */ + dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); + + cluster = new ClusterInfo(); + cluster.offset = written; + clustersOffsetsSizes.add(cluster); + + dump(new byte[]{ + 0x10, 0x00, 0x00, 0x00, + /* timestamp */ + (byte) 0xe7 + }, stream); + + dump(encode(timecode, true), stream); + } + + return offset; + } + + private void makeEBML(final SharpStream stream) throws IOException { + // default 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(final int internalTrackId, final 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)); + + /* codec delay*/ + if (track.codecDelay >= 0) { + buffer.add(new byte[]{0x56, (byte) 0xAA}); + buffer.add(encode(track.codecDelay, true)); + } + + /* codec seek pre-roll*/ + if (track.seekPreRoll >= 0) { + buffer.add(new byte[]{0x56, (byte) 0xBB}); + buffer.add(encode(track.seekPreRoll, true)); + } + + /* type */ + buffer.add(new byte[]{(byte) 0x83}); + buffer.add(encode(track.trackType, true)); + + /* default duration */ + if (track.defaultDuration >= 0) { + 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 int makeCuePoint(final int internalTrackId, final KeyFrame keyFrame, + final byte[] buffer) { + ArrayList cue = new ArrayList<>(5); + + /* CuePoint */ + cue.add(new byte[]{(byte) 0xbb}); + cue.add(null); + + /* CueTime */ + cue.add(new byte[]{(byte) 0xb3}); + cue.add(encode(keyFrame.duration, true)); + + /* CueTrackPosition */ + cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); + + int size = 0; + lengthFor(cue); + + for (byte[] buff : cue) { + System.arraycopy(buff, 0, buffer, size, buff.length); + size += buff.length; + } + + return size; + } + + private ArrayList makeCueTrackPosition(final int internalTrackId, + final 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.clusterPosition, true)); + + /* CueRelativePosition */ + if (keyFrame.relativePosition > 0) { + buffer.add(new byte[]{(byte) 0xf0}); + buffer.add(encode(keyFrame.relativePosition, true)); + } + + return lengthFor(buffer); + } + + private void makeEbmlVoid(final SharpStream out, final int amount, final boolean wipe) + throws IOException { + int size = amount; + + /* ebml void */ + outByteBuffer.putShort(0, (short) 0xec20); + outByteBuffer.putShort(2, (short) (size - 4)); + + dump(outBuffer, 4, out); + + if (wipe) { + size -= 4; + while (size > 0) { + int write = Math.min(size, outBuffer.length); + dump(outBuffer, write, out); + size -= write; + } + } + } + + private void dump(final byte[] buffer, final SharpStream stream) throws IOException { + dump(buffer, buffer.length, stream); + } + + private void dump(final byte[] buffer, final int count, final SharpStream stream) + throws IOException { + stream.write(buffer, 0, count); + written += count; + } + + private void dump(final ArrayList buffers, final SharpStream stream) + throws IOException { + for (byte[] buffer : buffers) { + stream.write(buffer); + written += buffer.length; + } + } + + private ArrayList lengthFor(final 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(final long number, final 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 - 1f) / 8f); + + int shift = 0; + for (int i = length - 1; i >= 0; i--, shift += 8) { + long b = number >>> shift; + if (!withLength && i == marker) { + b = b | (0x80 >>> (length - 1)); + } + buffer[offset + i] = (byte) b; + } + + if (withLength) { + buffer[0] = (byte) (0x80 | length); + } + + return buffer; + } + + @SuppressWarnings("CharsetObjectCanBeUsed") + private ArrayList encode(final String value) { + byte[] str = null; + try { + str = value.getBytes("utf-8"); + } catch (UnsupportedEncodingException e) { + // never throws + } + + ArrayList buffer = new ArrayList<>(2); + // noinspection ConstantConditions + buffer.add(encode(str.length, false)); + buffer.add(str); + + return buffer; + } + + private boolean valid(final 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; + } + + static class KeyFrame { + KeyFrame(final long segment, final long cluster, final long block, final long timecode) { + clusterPosition = cluster - segment; + relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE); + duration = timecode; + } + + final long clusterPosition; + final int relativePosition; + final long duration; + } + + static class Block { + InputStream data; + int trackNumber; + byte flags; + int dataSize; + long absoluteTimecode; + + boolean isKeyframe() { + return (flags & 0x80) == 0x80; + } + + @NonNull + @Override + public String toString() { + return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, + isKeyframe(), absoluteTimecode); + } + } + + static class ClusterInfo { + long offset; + int size; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipelegacy/streams/io/SharpStream.java new file mode 100644 index 000000000..d2d6640cf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/streams/io/SharpStream.java @@ -0,0 +1,62 @@ +package org.schabi.newpipelegacy.streams.io; + +import java.io.Closeable; +import java.io.IOException; + +/** + * Based on C#'s Stream class. + */ +public abstract class SharpStream implements Closeable { + 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 long available(); + + public abstract void rewind() throws IOException; + + public abstract boolean isClosed(); + + @Override + public abstract void close(); + + public abstract boolean canRewind(); + + public abstract boolean canRead(); + + public abstract boolean canWrite(); + + public boolean canSetLength() { + return false; + } + + public boolean canSeek() { + return false; + } + + 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 void flush() throws IOException { + // STUB + } + + public void setLength(final long length) throws IOException { + throw new IOException("Not implemented"); + } + + public void seek(final long offset) throws IOException { + throw new IOException("Not implemented"); + } + + public long length() throws IOException { + throw new UnsupportedOperationException("Unsupported operation"); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/AndroidTvUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/AndroidTvUtils.java new file mode 100644 index 000000000..faf843589 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/AndroidTvUtils.java @@ -0,0 +1,66 @@ +package org.schabi.newpipelegacy.util; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.BatteryManager; +import android.os.Build; +import android.view.KeyEvent; + +import org.schabi.newpipelegacy.App; + +import static android.content.Context.BATTERY_SERVICE; +import static android.content.Context.UI_MODE_SERVICE; + +public final class AndroidTvUtils { + + private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; + private static Boolean isTV = null; + + private AndroidTvUtils() { + } + + public static boolean isTv(final Context context) { + if (AndroidTvUtils.isTV != null) { + return AndroidTvUtils.isTV; + } + + PackageManager pm = App.getApp().getPackageManager(); + + // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check + boolean isTv = ((UiModeManager) context.getSystemService(UI_MODE_SERVICE)) + .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION + || pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV) + || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); + + // from https://stackoverflow.com/a/58932366 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + boolean isBatteryAbsent = ((BatteryManager) context.getSystemService(BATTERY_SERVICE)) + .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) == 0; + isTv = isTv || (isBatteryAbsent + && !pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) + && pm.hasSystemFeature(PackageManager.FEATURE_USB_HOST) + && pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET)); + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + isTv = isTv || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); + } + + AndroidTvUtils.isTV = isTv; + return AndroidTvUtils.isTV; + } + + public static boolean isConfirmKey(final int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_CENTER: + case KeyEvent.KEYCODE_ENTER: + case KeyEvent.KEYCODE_SPACE: + case KeyEvent.KEYCODE_NUMPAD_ENTER: + return true; + default: + return false; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/AnimationUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/AnimationUtils.java new file mode 100644 index 000000000..fdb3b3423 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/AnimationUtils.java @@ -0,0 +1,486 @@ +/* + * Copyright 2018 Mauricio Colli + * AnimationUtils.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.util; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ArgbEvaluator; +import android.animation.ValueAnimator; +import android.content.res.ColorStateList; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.ColorInt; +import androidx.annotation.FloatRange; +import androidx.core.view.ViewCompat; +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import org.schabi.newpipelegacy.MainActivity; + +public final class AnimationUtils { + private static final String TAG = "AnimationUtils"; + private static final boolean DEBUG = MainActivity.DEBUG; + + private AnimationUtils() { } + + public static void animateView(final View view, final boolean enterOrExit, + final long duration) { + animateView(view, Type.ALPHA, enterOrExit, duration, 0, null); + } + + public static void animateView(final View view, final boolean enterOrExit, + final long duration, final long delay) { + animateView(view, Type.ALPHA, enterOrExit, duration, delay, null); + } + + public static void animateView(final View view, final boolean enterOrExit, final long duration, + final long delay, final Runnable execOnEnd) { + animateView(view, Type.ALPHA, enterOrExit, duration, delay, execOnEnd); + } + + public static void animateView(final View view, final Type animationType, + final boolean enterOrExit, final long duration) { + animateView(view, animationType, enterOrExit, duration, 0, null); + } + + public static void animateView(final View view, final Type animationType, + final boolean enterOrExit, final long duration, + final long delay) { + animateView(view, animationType, enterOrExit, duration, delay, null); + } + + /** + * Animate the view. + * + * @param view view that will be animated + * @param animationType {@link Type} of the animation + * @param enterOrExit true to enter, false to exit + * @param duration how long the animation will take, in milliseconds + * @param delay how long the animation will wait to start, in milliseconds + * @param execOnEnd runnable that will be executed when the animation ends + */ + public static void animateView(final View view, final Type animationType, + final boolean enterOrExit, final long duration, + final long delay, final Runnable execOnEnd) { + if (DEBUG) { + String id; + try { + id = view.getResources().getResourceEntryName(view.getId()); + } catch (Exception e) { + id = view.getId() + ""; + } + + String msg = String.format("%8s → [%s:%s] [%s %s:%s] execOnEnd=%s", enterOrExit, + view.getClass().getSimpleName(), id, animationType, duration, delay, execOnEnd); + Log.d(TAG, "animateView()" + msg); + } + + if (view.getVisibility() == View.VISIBLE && enterOrExit) { + if (DEBUG) { + Log.d(TAG, "animateView() view was already visible > view = [" + view + "]"); + } + view.animate().setListener(null).cancel(); + view.setVisibility(View.VISIBLE); + view.setAlpha(1f); + if (execOnEnd != null) { + execOnEnd.run(); + } + return; + } else if ((view.getVisibility() == View.GONE || view.getVisibility() == View.INVISIBLE) + && !enterOrExit) { + if (DEBUG) { + Log.d(TAG, "animateView() view was already gone > view = [" + view + "]"); + } + view.animate().setListener(null).cancel(); + view.setVisibility(View.GONE); + view.setAlpha(0f); + if (execOnEnd != null) { + execOnEnd.run(); + } + return; + } + + view.animate().setListener(null).cancel(); + view.setVisibility(View.VISIBLE); + + switch (animationType) { + case ALPHA: + animateAlpha(view, enterOrExit, duration, delay, execOnEnd); + break; + case SCALE_AND_ALPHA: + animateScaleAndAlpha(view, enterOrExit, duration, delay, execOnEnd); + break; + case LIGHT_SCALE_AND_ALPHA: + animateLightScaleAndAlpha(view, enterOrExit, duration, delay, execOnEnd); + break; + case SLIDE_AND_ALPHA: + animateSlideAndAlpha(view, enterOrExit, duration, delay, execOnEnd); + break; + case LIGHT_SLIDE_AND_ALPHA: + animateLightSlideAndAlpha(view, enterOrExit, duration, delay, execOnEnd); + break; + } + } + + /** + * Animate the background color of a view. + * + * @param view the view to animate + * @param duration the duration of the animation + * @param colorStart the background color to start with + * @param colorEnd the background color to end with + */ + public static void animateBackgroundColor(final View view, final long duration, + @ColorInt final int colorStart, + @ColorInt final int colorEnd) { + if (DEBUG) { + Log.d(TAG, "animateBackgroundColor() called with: " + + "view = [" + view + "], duration = [" + duration + "], " + + "colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]"); + } + + final int[][] empty = new int[][]{new int[0]}; + ValueAnimator viewPropertyAnimator = ValueAnimator + .ofObject(new ArgbEvaluator(), colorStart, colorEnd); + viewPropertyAnimator.setInterpolator(new FastOutSlowInInterpolator()); + viewPropertyAnimator.setDuration(duration); + viewPropertyAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(final ValueAnimator animation) { + ViewCompat.setBackgroundTintList(view, + new ColorStateList(empty, new int[]{(int) animation.getAnimatedValue()})); + } + }); + viewPropertyAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + ViewCompat.setBackgroundTintList(view, + new ColorStateList(empty, new int[]{colorEnd})); + } + + @Override + public void onAnimationCancel(final Animator animation) { + onAnimationEnd(animation); + } + }); + viewPropertyAnimator.start(); + } + + /** + * Animate the text color of any view that extends {@link TextView} (Buttons, EditText...). + * + * @param view the text view to animate + * @param duration the duration of the animation + * @param colorStart the text color to start with + * @param colorEnd the text color to end with + */ + public static void animateTextColor(final TextView view, final long duration, + @ColorInt final int colorStart, + @ColorInt final int colorEnd) { + if (DEBUG) { + Log.d(TAG, "animateTextColor() called with: " + + "view = [" + view + "], duration = [" + duration + "], " + + "colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]"); + } + + ValueAnimator viewPropertyAnimator = ValueAnimator + .ofObject(new ArgbEvaluator(), colorStart, colorEnd); + viewPropertyAnimator.setInterpolator(new FastOutSlowInInterpolator()); + viewPropertyAnimator.setDuration(duration); + viewPropertyAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { + @Override + public void onAnimationUpdate(final ValueAnimator animation) { + view.setTextColor((int) animation.getAnimatedValue()); + } + }); + viewPropertyAnimator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setTextColor(colorEnd); + } + + @Override + public void onAnimationCancel(final Animator animation) { + view.setTextColor(colorEnd); + } + }); + viewPropertyAnimator.start(); + } + + public static ValueAnimator animateHeight(final View view, final long duration, + final int targetHeight) { + final int height = view.getHeight(); + if (DEBUG) { + Log.d(TAG, "animateHeight: duration = [" + duration + "], " + + "from " + height + " to → " + targetHeight + " in: " + view); + } + + ValueAnimator animator = ValueAnimator.ofFloat(height, targetHeight); + animator.setInterpolator(new FastOutSlowInInterpolator()); + animator.setDuration(duration); + animator.addUpdateListener(animation -> { + final float value = (float) animation.getAnimatedValue(); + view.getLayoutParams().height = (int) value; + view.requestLayout(); + }); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + + @Override + public void onAnimationCancel(final Animator animation) { + view.getLayoutParams().height = targetHeight; + view.requestLayout(); + } + }); + animator.start(); + + return animator; + } + + public static void animateRotation(final View view, final long duration, + final int targetRotation) { + if (DEBUG) { + Log.d(TAG, "animateRotation: duration = [" + duration + "], " + + "from " + view.getRotation() + " to → " + targetRotation + " in: " + view); + } + view.animate().setListener(null).cancel(); + + view.animate() + .rotation(targetRotation).setDuration(duration) + .setInterpolator(new FastOutSlowInInterpolator()) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(final Animator animation) { + view.setRotation(targetRotation); + } + + @Override + public void onAnimationEnd(final Animator animation) { + view.setRotation(targetRotation); + } + }).start(); + } + + private static void animateAlpha(final View view, final boolean enterOrExit, + final long duration, final long delay, + final Runnable execOnEnd) { + if (enterOrExit) { + view.animate().setInterpolator(new FastOutSlowInInterpolator()).alpha(1f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } else { + view.animate().setInterpolator(new FastOutSlowInInterpolator()).alpha(0f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setVisibility(View.GONE); + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Internals + //////////////////////////////////////////////////////////////////////////*/ + + private static void animateScaleAndAlpha(final View view, final boolean enterOrExit, + final long duration, final long delay, + final Runnable execOnEnd) { + if (enterOrExit) { + view.setScaleX(.8f); + view.setScaleY(.8f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()) + .alpha(1f).scaleX(1f).scaleY(1f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } else { + view.setScaleX(1f); + view.setScaleY(1f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()) + .alpha(0f).scaleX(.8f).scaleY(.8f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setVisibility(View.GONE); + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } + } + + private static void animateLightScaleAndAlpha(final View view, final boolean enterOrExit, + final long duration, final long delay, + final Runnable execOnEnd) { + if (enterOrExit) { + view.setAlpha(.5f); + view.setScaleX(.95f); + view.setScaleY(.95f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()) + .alpha(1f).scaleX(1f).scaleY(1f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } else { + view.setAlpha(1f); + view.setScaleX(1f); + view.setScaleY(1f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()) + .alpha(0f).scaleX(.95f).scaleY(.95f) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setVisibility(View.GONE); + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } + } + + private static void animateSlideAndAlpha(final View view, final boolean enterOrExit, + final long duration, final long delay, + final Runnable execOnEnd) { + if (enterOrExit) { + view.setTranslationY(-view.getHeight()); + view.setAlpha(0f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()).alpha(1f).translationY(0) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } else { + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()) + .alpha(0f).translationY(-view.getHeight()) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setVisibility(View.GONE); + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } + } + + private static void animateLightSlideAndAlpha(final View view, final boolean enterOrExit, + final long duration, final long delay, + final Runnable execOnEnd) { + if (enterOrExit) { + view.setTranslationY(-view.getHeight() / 2); + view.setAlpha(0f); + view.animate() + .setInterpolator(new FastOutSlowInInterpolator()).alpha(1f).translationY(0) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } else { + view.animate().setInterpolator(new FastOutSlowInInterpolator()) + .alpha(0f).translationY(-view.getHeight() / 2) + .setDuration(duration).setStartDelay(delay) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + view.setVisibility(View.GONE); + if (execOnEnd != null) { + execOnEnd.run(); + } + } + }).start(); + } + } + + public static void slideUp(final View view, final long duration, final long delay, + @FloatRange(from = 0.0f, to = 1.0f) + final 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(); + } + + public enum Type { + ALPHA, + SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, + SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/BitmapUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/BitmapUtils.java new file mode 100644 index 000000000..035409e71 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/BitmapUtils.java @@ -0,0 +1,45 @@ +package org.schabi.newpipelegacy.util; + +import android.graphics.Bitmap; + +import androidx.annotation.Nullable; + +public final class BitmapUtils { + private BitmapUtils() { } + + @Nullable + public static Bitmap centerCrop(final Bitmap inputBitmap, final int newWidth, + final int newHeight) { + if (inputBitmap == null || inputBitmap.isRecycled()) { + return null; + } + + float sourceWidth = inputBitmap.getWidth(); + float sourceHeight = inputBitmap.getHeight(); + + float xScale = newWidth / sourceWidth; + float yScale = newHeight / sourceHeight; + + float newXScale; + float newYScale; + + if (yScale > xScale) { + newXScale = xScale / yScale; + newYScale = 1.0f; + } else { + newXScale = 1.0f; + newYScale = yScale / xScale; + } + + float scaledWidth = newXScale * sourceWidth; + float scaledHeight = newYScale * sourceHeight; + + int left = (int) ((sourceWidth - scaledWidth) / 2); + int top = (int) ((sourceHeight - scaledHeight) / 2); + int width = (int) scaledWidth; + int height = (int) scaledHeight; + + return Bitmap.createBitmap(inputBitmap, left, top, width, height); + } + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipelegacy/util/CommentTextOnTouchListener.java new file mode 100644 index 000000000..6825cb2dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/CommentTextOnTouchListener.java @@ -0,0 +1,131 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.view.MotionEvent; +import android.view.View; +import android.widget.TextView; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; + +public class CommentTextOnTouchListener implements View.OnTouchListener { + public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); + + private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)"); + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + if (!(v instanceof TextView)) { + return false; + } + TextView widget = (TextView) v; + Object text = widget.getText(); + if (text instanceof Spanned) { + Spannable buffer = (Spannable) text; + + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + ClickableSpan[] link = buffer.getSpans(off, off, + ClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + boolean handled = false; + if (link[0] instanceof URLSpan) { + handled = handleUrl(v.getContext(), (URLSpan) link[0]); + } + if (!handled) { + link[0].onClick(widget); + } + } else if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(buffer, + buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + } + return true; + } + } + } + return false; + } + + private boolean handleUrl(final Context context, final URLSpan urlSpan) { + String url = urlSpan.getURL(); + int seconds = -1; + Matcher matcher = TIMESTAMP_PATTERN.matcher(url); + if (matcher.matches()) { + url = matcher.group(1); + seconds = Integer.parseInt(matcher.group(2)); + } + StreamingService service; + StreamingService.LinkType linkType; + try { + service = NewPipe.getServiceByUrl(url); + linkType = service.getLinkTypeByUrl(url); + } catch (ExtractionException e) { + return false; + } + if (linkType == StreamingService.LinkType.NONE) { + return false; + } + if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { + return playOnPopup(context, url, service, seconds); + } else { + NavigationHelper.openRouterActivity(context, url); + return true; + } + } + + private boolean playOnPopup(final Context context, final String url, + final StreamingService service, final int seconds) { + LinkHandlerFactory factory = service.getStreamLHFactory(); + String cleanUrl = null; + try { + cleanUrl = factory.getUrl(factory.getId(url)); + } catch (ParsingException e) { + return false; + } + Single single = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); + single.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(info -> { + PlayQueue playQueue = new SinglePlayQueue((StreamInfo) info, seconds * 1000); + NavigationHelper.playOnPopupPlayer(context, playQueue, false); + }); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/Constants.java b/app/src/main/java/org/schabi/newpipelegacy/util/Constants.java new file mode 100644 index 000000000..61b469914 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/Constants.java @@ -0,0 +1,17 @@ +package org.schabi.newpipelegacy.util; + +public final class Constants { + public static final String KEY_SERVICE_ID = "key_service_id"; + public static final String KEY_URL = "key_url"; + public static final String KEY_TITLE = "key_title"; + public static final String KEY_LINK_TYPE = "key_link_type"; + public static final String KEY_OPEN_SEARCH = "key_open_search"; + public static final String KEY_SEARCH_STRING = "key_search_string"; + + public static final String KEY_THEME_CHANGE = "key_theme_change"; + public static final String KEY_MAIN_PAGE_CHANGE = "key_main_page_change"; + + public static final int NO_SERVICE_ID = -1; + + private Constants() { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ConstantsKt.kt b/app/src/main/java/org/schabi/newpipelegacy/util/ConstantsKt.kt new file mode 100644 index 000000000..cead4d8b9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ConstantsKt.kt @@ -0,0 +1,6 @@ +package org.schabi.newpipelegacy.util + +/** + * Default duration when using throttle functions across the app, in milliseconds. + */ +const val DEFAULT_THROTTLE_TIMEOUT = 120L diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/CookieUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/CookieUtils.java new file mode 100644 index 000000000..62faf798a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/CookieUtils.java @@ -0,0 +1,25 @@ +package org.schabi.newpipelegacy.util; + +import android.text.TextUtils; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +public final class CookieUtils { + private CookieUtils() { + } + + public static String concatCookies(final Collection cookieStrings) { + Set cookieSet = new HashSet<>(); + for (String cookies : cookieStrings) { + cookieSet.addAll(splitCookies(cookies)); + } + return TextUtils.join("; ", cookieSet).trim(); + } + + public static Set splitCookies(final String cookies) { + return new HashSet<>(Arrays.asList(cookies.split("; *"))); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ExceptionUtils.kt b/app/src/main/java/org/schabi/newpipelegacy/util/ExceptionUtils.kt new file mode 100644 index 000000000..9cc4ee369 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ExceptionUtils.kt @@ -0,0 +1,82 @@ +package org.schabi.newpipelegacy.util + +import java.io.IOException +import java.io.InterruptedIOException + +class ExceptionUtils { + companion object { + /** + * @return if throwable is related to Interrupted exceptions, or one of its causes is. + */ + @JvmStatic + fun isInterruptedCaused(throwable: Throwable): Boolean { + return hasExactCause(throwable, + InterruptedIOException::class.java, + InterruptedException::class.java) + } + + /** + * @return if throwable is related to network issues, or one of its causes is. + */ + @JvmStatic + fun isNetworkRelated(throwable: Throwable): Boolean { + return hasAssignableCause(throwable, + IOException::class.java) + } + + /** + * Calls [hasCause] with the `checkSubtypes` parameter set to false. + */ + @JvmStatic + fun hasExactCause(throwable: Throwable, vararg causesToCheck: Class<*>): Boolean { + return hasCause(throwable, false, *causesToCheck) + } + + /** + * Calls [hasCause] with the `checkSubtypes` parameter set to true. + */ + @JvmStatic + fun hasAssignableCause(throwable: Throwable?, vararg causesToCheck: Class<*>): Boolean { + return hasCause(throwable, true, *causesToCheck) + } + + /** + * Check if throwable has some cause from the causes to check, or is itself in it. + * + * If `checkIfAssignable` is true, not only the exact type will be considered equals, but also its subtypes. + * + * @param throwable throwable that will be checked. + * @param checkSubtypes if subtypes are also checked. + * @param causesToCheck an array of causes to check. + * + * @see Class.isAssignableFrom + */ + @JvmStatic + tailrec fun hasCause(throwable: Throwable?, checkSubtypes: Boolean, vararg causesToCheck: Class<*>): Boolean { + if (throwable == null) { + return false + } + + // Check if throwable is a subtype of any of the causes to check + causesToCheck.forEach { causeClass -> + if (checkSubtypes) { + if (causeClass.isAssignableFrom(throwable.javaClass)) { + return true + } + } else { + if (causeClass == throwable.javaClass) { + return true + } + } + } + + val currentCause: Throwable? = throwable.cause + // Check if cause is not pointing to the same instance, to avoid infinite loops. + if (throwable !== currentCause) { + return hasCause(currentCause, checkSubtypes, *causesToCheck) + } + + return false + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/ExtractorHelper.java new file mode 100644 index 000000000..7604eb9f3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ExtractorHelper.java @@ -0,0 +1,308 @@ +/* + * Copyright 2017 Mauricio Colli + * Extractors.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.util.Log; +import android.widget.Toast; + +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.ReCaptchaActivity; +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; +import org.schabi.newpipe.extractor.ListInfo; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.comments.CommentsInfo; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; +import org.schabi.newpipe.extractor.feed.FeedExtractor; +import org.schabi.newpipe.extractor.feed.FeedInfo; +import org.schabi.newpipe.extractor.kiosk.KioskInfo; +import org.schabi.newpipe.extractor.playlist.PlaylistInfo; +import org.schabi.newpipe.extractor.search.SearchInfo; +import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; + +import java.util.Collections; +import java.util.List; + +import io.reactivex.Maybe; +import io.reactivex.Single; + +public final class ExtractorHelper { + private static final String TAG = ExtractorHelper.class.getSimpleName(); + private static final InfoCache CACHE = InfoCache.getInstance(); + + private ExtractorHelper() { + //no instance + } + + private static void checkServiceId(final int serviceId) { + if (serviceId == Constants.NO_SERVICE_ID) { + throw new IllegalArgumentException("serviceId is NO_SERVICE_ID"); + } + } + + public static Single searchFor(final int serviceId, final String searchString, + final List contentFilter, + final String sortFilter) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + SearchInfo.getInfo(NewPipe.getService(serviceId), + NewPipe.getService(serviceId) + .getSearchQHFactory() + .fromQuery(searchString, contentFilter, sortFilter))); + } + + public static Single getMoreSearchItems(final int serviceId, + final String searchString, + final List contentFilter, + final String sortFilter, + final Page page) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + SearchInfo.getMoreItems(NewPipe.getService(serviceId), + NewPipe.getService(serviceId) + .getSearchQHFactory() + .fromQuery(searchString, contentFilter, sortFilter), page)); + + } + + public static Single> suggestionsFor(final int serviceId, final String query) { + checkServiceId(serviceId); + return Single.fromCallable(() -> { + SuggestionExtractor extractor = NewPipe.getService(serviceId) + .getSuggestionExtractor(); + return extractor != null + ? extractor.suggestionList(query) + : Collections.emptyList(); + }); + } + + public static Single getStreamInfo(final int serviceId, final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM, + Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url))); + } + + public static Single getChannelInfo(final int serviceId, final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL, + Single.fromCallable(() -> + ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); + } + + public static Single getMoreChannelItems(final int serviceId, final String url, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); + } + + public static Single> getFeedInfoFallbackToChannelInfo( + final int serviceId, final String url) { + final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> { + final StreamingService service = NewPipe.getService(serviceId); + final FeedExtractor feedExtractor = service.getFeedExtractor(url); + + if (feedExtractor == null) { + return null; + } + + return FeedInfo.getInfo(feedExtractor); + }); + + return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true)); + } + + public static Single getCommentsInfo(final int serviceId, final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT, + Single.fromCallable(() -> + CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); + } + + public static Single getMoreCommentItems(final int serviceId, + final CommentsInfo info, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); + } + + public static Single getPlaylistInfo(final int serviceId, final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, + Single.fromCallable(() -> + PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); + } + + public static Single getMorePlaylistItems(final int serviceId, final String url, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); + } + + public static Single getKioskInfo(final int serviceId, final String url, + final boolean forceLoad) { + return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, + Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); + } + + public static Single getMoreKioskItems(final int serviceId, final String url, + final Page nextPage) { + return Single.fromCallable(() -> + KioskInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Check if we can load it from the cache (forceLoad parameter), if we can't, + * load from the network (Single loadFromNetwork) + * and put the results in the cache. + * + * @param the item type's class that extends {@link Info} + * @param forceLoad whether to force loading from the network instead of from the cache + * @param serviceId the service to load from + * @param url the URL to load + * @param infoType the {@link InfoItem.InfoType} of the item + * @param loadFromNetwork the {@link Single} to load the item from the network + * @return a {@link Single} that loads the item + */ + private static Single checkCache(final boolean forceLoad, + final int serviceId, final String url, + final InfoItem.InfoType infoType, + final Single loadFromNetwork) { + checkServiceId(serviceId); + Single actualLoadFromNetwork = loadFromNetwork + .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType)); + + Single load; + if (forceLoad) { + CACHE.removeInfo(serviceId, url, infoType); + load = actualLoadFromNetwork; + } else { + load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType), + actualLoadFromNetwork.toMaybe()) + .firstElement() // Take the first valid + .toSingle(); + } + + return load; + } + + /** + * Default implementation uses the {@link InfoCache} to get cached results. + * + * @param the item type's class that extends {@link Info} + * @param serviceId the service to load from + * @param url the URL to load + * @param infoType the {@link InfoItem.InfoType} of the item + * @return a {@link Single} that loads the item + */ + private static Maybe loadFromCache(final int serviceId, final String url, + final InfoItem.InfoType infoType) { + checkServiceId(serviceId); + return Maybe.defer(() -> { + //noinspection unchecked + I info = (I) CACHE.getFromKey(serviceId, url, infoType); + if (MainActivity.DEBUG) { + Log.d(TAG, "loadFromCache() called, info > " + info); + } + + // Only return info if it's not null (it is cached) + if (info != null) { + return Maybe.just(info); + } + + return Maybe.empty(); + }); + } + + public static boolean isCached(final int serviceId, final String url, + final InfoItem.InfoType infoType) { + return null != loadFromCache(serviceId, url, infoType).blockingGet(); + } + + /** + * A simple and general error handler that show a Toast for known exceptions, + * and for others, opens the report error activity with the (optional) error message. + * + * @param context Android app context + * @param serviceId the service the exception happened in + * @param url the URL where the exception happened + * @param exception the exception to be handled + * @param userAction the action of the user that caused the exception + * @param optionalErrorMessage the optional error message + */ + public static void handleGeneralException(final Context context, final int serviceId, + final String url, final Throwable exception, + final UserAction userAction, + final String optionalErrorMessage) { + final Handler handler = new Handler(context.getMainLooper()); + + handler.post(() -> { + if (exception instanceof ReCaptchaException) { + Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); + // Starting ReCaptcha Challenge Activity + Intent intent = new Intent(context, ReCaptchaActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } else if (ExceptionUtils.isNetworkRelated(exception)) { + Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show(); + } else if (exception instanceof ContentNotAvailableException) { + Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show(); + } else if (exception instanceof ContentNotSupportedException) { + Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show(); + } else { + int errorId = exception instanceof YoutubeStreamExtractor.DecryptException + ? R.string.youtube_signature_decryption_error + : exception instanceof ParsingException + ? R.string.parsing_error : R.string.general_error; + ErrorActivity.reportError(handler, context, exception, MainActivity.class, null, + ErrorActivity.ErrorInfo.make(userAction, serviceId == -1 ? "none" + : NewPipe.getNameOfService(serviceId), + url + (optionalErrorMessage == null ? "" + : optionalErrorMessage), errorId)); + } + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/FallbackViewHolder.java b/app/src/main/java/org/schabi/newpipelegacy/util/FallbackViewHolder.java new file mode 100644 index 000000000..f52e31a66 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/FallbackViewHolder.java @@ -0,0 +1,11 @@ +package org.schabi.newpipelegacy.util; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +public class FallbackViewHolder extends RecyclerView.ViewHolder { + public FallbackViewHolder(final View itemView) { + super(itemView); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/FilePickerActivityHelper.java new file mode 100644 index 000000000..6a5690fc3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/FilePickerActivityHelper.java @@ -0,0 +1,167 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SortedList; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerFragment; + +import org.schabi.newpipelegacy.R; + +import java.io.File; + +public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { + private CustomFilePickerFragment currentFragment; + + public static Intent chooseSingleFile(@NonNull final Context context) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) + .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); + } + + public static Intent chooseFileToSave(@NonNull final Context context, + @Nullable final String startPath) { + return new Intent(context, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_NEW_FILE); + } + + public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) { + if (uri.getAuthority() == null) { + return false; + } + return uri.getAuthority().startsWith(context.getPackageName()); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + if (ThemeHelper.isLightThemeSelected(this)) { + this.setTheme(R.style.FilePickerThemeLight); + } else { + this.setTheme(R.style.FilePickerThemeDark); + } + super.onCreate(savedInstanceState); + } + + @Override + public void onBackPressed() { + // If at top most level, normal behaviour + if (currentFragment.isBackTop()) { + super.onBackPressed(); + } else { + // Else go up + currentFragment.goUp(); + } + } + + @Override + protected AbstractFilePickerFragment getFragment(@Nullable final String startPath, + final int mode, + final boolean allowMultiple, + final boolean allowCreateDir, + final boolean allowExistingFile, + final boolean singleClick) { + final CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + fragment.setArgs(startPath != null ? startPath + : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + currentFragment = fragment; + return currentFragment; + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public static class CustomFilePickerFragment extends FilePickerFragment { + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + return super.onCreateView(inflater, container, savedInstanceState); + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, + final int viewType) { + final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType); + + final View view = viewHolder.itemView.findViewById(android.R.id.text1); + if (view instanceof TextView) { + ((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimension(R.dimen.file_picker_items_text_size)); + } + + return viewHolder; + } + + @Override + public void onClickOk(@NonNull final View view) { + if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) { + if (mToast != null) { + mToast.cancel(); + } + mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, + Toast.LENGTH_SHORT); + mToast.show(); + return; + } + + super.onClickOk(view); + } + + @Override + protected boolean isItemVisible(@NonNull final File file) { + if (file.isDirectory() && file.isHidden()) { + return true; + } + return super.isItemVisible(file); + } + + public File getBackTop() { + if (getArguments() == null) { + return Environment.getExternalStorageDirectory(); + } + + final String path = getArguments().getString(KEY_START_PATH, "/"); + if (path.contains(Environment.getExternalStorageDirectory().getPath())) { + return Environment.getExternalStorageDirectory(); + } + + return getPath(path); + } + + public boolean isBackTop() { + return compareFiles(mCurrentPath, + getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0; + } + + @Override + public void onLoadFinished(final Loader> loader, + final SortedList data) { + super.onLoadFinished(loader, data); + layoutManager.scrollToPosition(0); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/FilenameUtils.java new file mode 100644 index 000000000..11a364da9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/FilenameUtils.java @@ -0,0 +1,68 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.schabi.newpipelegacy.R; + +import java.util.regex.Pattern; + +public final class FilenameUtils { + private static final String CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+"; + private static final String CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+"; + + private FilenameUtils() { } + + /** + * #143 #44 #42 #22: make sure that the filename does not contain illegal chars. + * + * @param context the context to retrieve strings and preferences from + * @param title the title to create a filename from + * @return the filename + */ + public static String createFilename(final Context context, final String title) { + SharedPreferences sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context); + + final String charsetLd = context.getString(R.string.charset_letters_and_digits_value); + final String charsetMs = context.getString(R.string.charset_most_special_value); + final String defaultCharset = context.getString(R.string.default_file_charset_value); + + final String replacementChar = sharedPreferences.getString( + context.getString(R.string.settings_file_replacement_character_key), "_"); + String selectedCharset = sharedPreferences.getString( + context.getString(R.string.settings_file_charset_key), null); + + final String charset; + + if (selectedCharset == null || selectedCharset.isEmpty()) { + selectedCharset = defaultCharset; + } + + if (selectedCharset.equals(charsetLd)) { + charset = CHARSET_ONLY_LETTERS_AND_DIGITS; + } else if (selectedCharset.equals(charsetMs)) { + charset = CHARSET_MOST_SPECIAL; + } else { + charset = selectedCharset; // Is the user using a custom charset? + } + + Pattern pattern = Pattern.compile(charset); + + return createFilename(title, pattern, replacementChar); + } + + /** + * Create a valid filename. + * + * @param title the title to create a filename from + * @param invalidCharacters patter matching invalid characters + * @param replacementChar the replacement + * @return the filename + */ + private static String createFilename(final String title, final Pattern invalidCharacters, + final String replacementChar) { + return title.replaceAll(invalidCharacters.pattern(), replacementChar); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ImageDisplayConstants.java b/app/src/main/java/org/schabi/newpipelegacy/util/ImageDisplayConstants.java new file mode 100644 index 000000000..20a5eb6a2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ImageDisplayConstants.java @@ -0,0 +1,60 @@ +package org.schabi.newpipelegacy.util; + +import android.graphics.Bitmap; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; + +import org.schabi.newpipelegacy.R; + +public final class ImageDisplayConstants { + private static final int BITMAP_FADE_IN_DURATION_MILLIS = 250; + + /** + * This constant contains the base display options. + */ + private static final DisplayImageOptions BASE_DISPLAY_IMAGE_OPTIONS = + new DisplayImageOptions.Builder() + .cacheInMemory(true) + .cacheOnDisk(true) + .resetViewBeforeLoading(true) + .bitmapConfig(Bitmap.Config.RGB_565) + .imageScaleType(ImageScaleType.EXACTLY) + .displayer(new FadeInBitmapDisplayer(BITMAP_FADE_IN_DURATION_MILLIS)) + .build(); + + /*////////////////////////////////////////////////////////////////////////// + // DisplayImageOptions default configurations + //////////////////////////////////////////////////////////////////////////*/ + + public static final DisplayImageOptions DISPLAY_AVATAR_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.buddy) + .showImageOnFail(R.drawable.buddy) + .build(); + + public static final DisplayImageOptions DISPLAY_THUMBNAIL_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.dummy_thumbnail) + .showImageOnFail(R.drawable.dummy_thumbnail) + .build(); + + public static final DisplayImageOptions DISPLAY_BANNER_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.channel_banner) + .showImageOnFail(R.drawable.channel_banner) + .build(); + + public static final DisplayImageOptions DISPLAY_PLAYLIST_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .showImageForEmptyUri(R.drawable.dummy_thumbnail_playlist) + .showImageOnFail(R.drawable.dummy_thumbnail_playlist) + .build(); + + private ImageDisplayConstants() { } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/InfoCache.java b/app/src/main/java/org/schabi/newpipelegacy/util/InfoCache.java new file mode 100644 index 000000000..49c990013 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/InfoCache.java @@ -0,0 +1,159 @@ +/* + * Copyright 2017 Mauricio Colli + * InfoCache.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.util; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.LruCache; + +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipe.extractor.Info; +import org.schabi.newpipe.extractor.InfoItem; + +import java.util.Map; + +public final class InfoCache { + private final String TAG = getClass().getSimpleName(); + private static final boolean DEBUG = MainActivity.DEBUG; + + private static final InfoCache INSTANCE = new InfoCache(); + private static final int MAX_ITEMS_ON_CACHE = 60; + /** + * Trim the cache to this size. + */ + private static final int TRIM_CACHE_TO = 30; + + private static final LruCache LRU_CACHE = new LruCache<>(MAX_ITEMS_ON_CACHE); + + private InfoCache() { + // no instance + } + + public static InfoCache getInstance() { + return INSTANCE; + } + + @NonNull + private static String keyOf(final int serviceId, @NonNull final String url, + @NonNull final InfoItem.InfoType infoType) { + return serviceId + url + infoType.toString(); + } + + private static void removeStaleCache() { + for (Map.Entry entry : InfoCache.LRU_CACHE.snapshot().entrySet()) { + final CacheData data = entry.getValue(); + if (data != null && data.isExpired()) { + InfoCache.LRU_CACHE.remove(entry.getKey()); + } + } + } + + @Nullable + private static Info getInfo(@NonNull final String key) { + final CacheData data = InfoCache.LRU_CACHE.get(key); + if (data == null) { + return null; + } + + if (data.isExpired()) { + InfoCache.LRU_CACHE.remove(key); + return null; + } + + return data.info; + } + + @Nullable + public Info getFromKey(final int serviceId, @NonNull final String url, + @NonNull final InfoItem.InfoType infoType) { + if (DEBUG) { + Log.d(TAG, "getFromKey() called with: " + + "serviceId = [" + serviceId + "], url = [" + url + "]"); + } + synchronized (LRU_CACHE) { + return getInfo(keyOf(serviceId, url, infoType)); + } + } + + public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info, + @NonNull final InfoItem.InfoType infoType) { + if (DEBUG) { + Log.d(TAG, "putInfo() called with: info = [" + info + "]"); + } + + final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); + synchronized (LRU_CACHE) { + final CacheData data = new CacheData(info, expirationMillis); + LRU_CACHE.put(keyOf(serviceId, url, infoType), data); + } + } + + public void removeInfo(final int serviceId, @NonNull final String url, + @NonNull final InfoItem.InfoType infoType) { + if (DEBUG) { + Log.d(TAG, "removeInfo() called with: " + + "serviceId = [" + serviceId + "], url = [" + url + "]"); + } + synchronized (LRU_CACHE) { + LRU_CACHE.remove(keyOf(serviceId, url, infoType)); + } + } + + public void clearCache() { + if (DEBUG) { + Log.d(TAG, "clearCache() called"); + } + synchronized (LRU_CACHE) { + LRU_CACHE.evictAll(); + } + } + + public void trimCache() { + if (DEBUG) { + Log.d(TAG, "trimCache() called"); + } + synchronized (LRU_CACHE) { + removeStaleCache(); + LRU_CACHE.trimToSize(TRIM_CACHE_TO); + } + } + + public long getSize() { + synchronized (LRU_CACHE) { + return LRU_CACHE.size(); + } + } + + private static final class CacheData { + private final long expireTimestamp; + private final Info info; + + private CacheData(@NonNull final Info info, final long timeoutMillis) { + this.expireTimestamp = System.currentTimeMillis() + timeoutMillis; + this.info = info; + } + + private boolean isExpired() { + return System.currentTimeMillis() > expireTimestamp; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipelegacy/util/KioskTranslator.java new file mode 100644 index 000000000..dde686970 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/KioskTranslator.java @@ -0,0 +1,69 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; + +import org.schabi.newpipelegacy.R; + +/** + * Created by Chrsitian Schabesberger on 28.09.17. + * KioskTranslator.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 final class KioskTranslator { + private KioskTranslator() { } + + public static String getTranslatedKioskName(final String kioskId, final Context c) { + switch (kioskId) { + case "Trending": + return c.getString(R.string.trending); + case "Top 50": + return c.getString(R.string.top_50); + case "New & hot": + return c.getString(R.string.new_and_hot); + case "Local": + return c.getString(R.string.local); + case "Recently added": + return c.getString(R.string.recently_added); + case "Most liked": + return c.getString(R.string.most_liked); + case "conferences": + return c.getString(R.string.conferences); + default: + return kioskId; + } + } + + public static int getKioskIcon(final String kioskId, final Context c) { + switch (kioskId) { + case "Trending": + case "Top 50": + case "New & hot": + case "conferences": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_hot); + case "Local": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_local); + case "Recently added": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_recent); + case "Most liked": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_thumb_up); + default: + return 0; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/KoreUtil.java b/app/src/main/java/org/schabi/newpipelegacy/util/KoreUtil.java new file mode 100644 index 000000000..08bfd6e2b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/KoreUtil.java @@ -0,0 +1,29 @@ +package org.schabi.newpipelegacy.util; + + +import android.content.Context; +import android.content.DialogInterface; + +import androidx.appcompat.app.AlertDialog; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ServiceList; + +public final class KoreUtil { + private KoreUtil() { } + + public static boolean isServiceSupportedByKore(final int serviceId) { + return (serviceId == ServiceList.YouTube.getServiceId() + || serviceId == ServiceList.SoundCloud.getServiceId()); + } + + public static void showInstallKoreDialog(final Context context) { + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setMessage(R.string.kore_not_found) + .setPositiveButton(R.string.install, (DialogInterface dialog, int which) -> + NavigationHelper.installKore(context)) + .setNegativeButton(R.string.cancel, (DialogInterface dialog, int which) -> { + }); + builder.create().show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/LayoutManagerSmoothScroller.java b/app/src/main/java/org/schabi/newpipelegacy/util/LayoutManagerSmoothScroller.java new file mode 100644 index 000000000..2ab04084a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/LayoutManagerSmoothScroller.java @@ -0,0 +1,46 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.graphics.PointF; + +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + +public class LayoutManagerSmoothScroller extends LinearLayoutManager { + public LayoutManagerSmoothScroller(final Context context) { + super(context, VERTICAL, false); + } + + public LayoutManagerSmoothScroller(final Context context, final int orientation, + final boolean reverseLayout) { + super(context, orientation, reverseLayout); + } + + @Override + public void smoothScrollToPosition(final RecyclerView recyclerView, + final RecyclerView.State state, final int position) { + RecyclerView.SmoothScroller smoothScroller + = new TopSnappedSmoothScroller(recyclerView.getContext()); + smoothScroller.setTargetPosition(position); + startSmoothScroll(smoothScroller); + } + + private class TopSnappedSmoothScroller extends LinearSmoothScroller { + TopSnappedSmoothScroller(final Context context) { + super(context); + + } + + @Override + public PointF computeScrollVectorForPosition(final int targetPosition) { + return LayoutManagerSmoothScroller.this + .computeScrollVectorForPosition(targetPosition); + } + + @Override + protected int getVerticalSnapPreference() { + return SNAP_TO_START; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ListHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/ListHelper.java new file mode 100644 index 000000000..8b9d2a00e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ListHelper.java @@ -0,0 +1,548 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.ConnectivityManager; +import android.preference.PreferenceManager; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +public final class ListHelper { + // Video format in order of quality. 0=lowest quality, n=highest quality + private static final List VIDEO_FORMAT_QUALITY_RANKING = + Arrays.asList(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); + + // Audio format in order of quality. 0=lowest quality, n=highest quality + private static final List AUDIO_FORMAT_QUALITY_RANKING = + Arrays.asList(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); + // Audio format in order of efficiency. 0=most efficient, n=least efficient + private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = + Arrays.asList(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3); + + private static final List HIGH_RESOLUTION_LIST + = Arrays.asList("1440p", "2160p", "1440p60", "2160p60"); + + private ListHelper() { } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + * @param context Android app context + * @param videoStreams list of the video streams to check + * @return index of the video stream with the default index + */ + public static int getDefaultResolutionIndex(final Context context, + final List videoStreams) { + String defaultResolution = computeDefaultResolution(context, + R.string.default_resolution_key, R.string.default_resolution_value); + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); + } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + * @param context Android app context + * @param videoStreams list of the video streams to check + * @param defaultResolution the default resolution to look for + * @return index of the video stream with the default index + */ + public static int getResolutionIndex(final Context context, + final List videoStreams, + final String defaultResolution) { + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); + } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + * @param context Android app context + * @param videoStreams list of the video streams to check + * @return index of the video stream with the default index + */ + public static int getPopupDefaultResolutionIndex(final Context context, + final List videoStreams) { + String defaultResolution = computeDefaultResolution(context, + R.string.default_popup_resolution_key, R.string.default_popup_resolution_value); + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); + } + + /** + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) + * @param context Android app context + * @param videoStreams list of the video streams to check + * @param defaultResolution the default resolution to look for + * @return index of the video stream with the default index + */ + public static int getPopupResolutionIndex(final Context context, + final List videoStreams, + final String defaultResolution) { + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); + } + + public static int getDefaultAudioFormat(final Context context, + final List audioStreams) { + MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_audio_format_key, + R.string.default_audio_format_value); + + // If the user has chosen to limit resolution to conserve mobile data + // usage then we should also limit our audio usage. + if (isLimitingDataUsage(context)) { + return getMostCompactAudioIndex(defaultFormat, audioStreams); + } else { + return getHighestQualityAudioIndex(defaultFormat, audioStreams); + } + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according with default format chosen by the user. + * + * @param context context to search for the format to give preference + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @return the sorted list + */ + public static List getSortedStreamVideosList(final Context context, + final List videoStreams, + final List + videoOnlyStreams, + final boolean ascendingOrder) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + boolean showHigherResolutions = preferences.getBoolean( + context.getString(R.string.show_higher_resolutions_key), false); + MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_video_format_key, + R.string.default_video_format_value); + + return getSortedStreamVideosList(defaultFormat, showHigherResolutions, videoStreams, + videoOnlyStreams, ascendingOrder); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private static String computeDefaultResolution(final Context context, final int key, + final int value) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + // Load the prefered resolution otherwise the best available + String resolution = preferences != null + ? preferences.getString(context.getString(key), context.getString(value)) + : context.getString(R.string.best_resolution_key); + + String maxResolution = getResolutionLimit(context); + if (maxResolution != null + && (resolution.equals(context.getString(R.string.best_resolution_key)) + || compareVideoStreamResolution(maxResolution, resolution) < 1)) { + resolution = maxResolution; + } + return resolution; + } + + /** + * Return the index of the default stream in the list, based on the parameters + * defaultResolution and defaultFormat. + * + * @param defaultResolution the default resolution to look for + * @param bestResolutionKey key of the best resolution + * @param defaultFormat the default fomat to look for + * @param videoStreams list of the video streams to check + * @return index of the default resolution&format + */ + static int getDefaultResolutionIndex(final String defaultResolution, + final String bestResolutionKey, + final MediaFormat defaultFormat, + final List videoStreams) { + if (videoStreams == null || videoStreams.isEmpty()) { + return -1; + } + + sortStreamList(videoStreams, false); + if (defaultResolution.equals(bestResolutionKey)) { + return 0; + } + + int defaultStreamIndex + = getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); + + // this is actually an error, + // but maybe there is really no stream fitting to the default value. + if (defaultStreamIndex == -1) { + return 0; + } + return defaultStreamIndex; + } + + /** + * Join the two lists of video streams (video_only and normal videos), + * and sort them according with default format chosen by the user. + * + * @param defaultFormat format to give preference + * @param showHigherResolutions show >1080p resolutions + * @param videoStreams normal videos list + * @param videoOnlyStreams video only stream list + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + * @return the sorted list + */ + static List getSortedStreamVideosList(final MediaFormat defaultFormat, + final boolean showHigherResolutions, + final List videoStreams, + final List videoOnlyStreams, + final boolean ascendingOrder) { + ArrayList retList = new ArrayList<>(); + HashMap hashMap = new HashMap<>(); + + if (videoOnlyStreams != null) { + for (VideoStream stream : videoOnlyStreams) { + if (!showHigherResolutions + && HIGH_RESOLUTION_LIST.contains(stream.getResolution())) { + continue; + } + retList.add(stream); + } + } + if (videoStreams != null) { + for (VideoStream stream : videoStreams) { + if (!showHigherResolutions + && HIGH_RESOLUTION_LIST.contains(stream.getResolution())) { + continue; + } + retList.add(stream); + } + } + + // Add all to the hashmap + for (VideoStream videoStream : retList) { + hashMap.put(videoStream.getResolution(), videoStream); + } + + // Override the values when the key == resolution, with the defaultFormat + for (VideoStream videoStream : retList) { + if (videoStream.getFormat() == defaultFormat) { + hashMap.put(videoStream.getResolution(), videoStream); + } + } + + retList.clear(); + retList.addAll(hashMap.values()); + sortStreamList(retList, ascendingOrder); + return retList; + } + + /** + * Sort the streams list depending on the parameter ascendingOrder; + *

+ * It works like that:
+ * - Take a string resolution, remove the letters, replace "0p60" (for 60fps videos) with "1" + * and sort by the greatest:
+ *

+     *      720p     ->  720
+     *      720p60   ->  721
+     *      360p     ->  360
+     *      1080p    ->  1080
+     *      1080p60  ->  1081
+     * 
+ * ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 + * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360
+ * + * @param videoStreams list that the sorting will be applied + * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest + */ + private static void sortStreamList(final List videoStreams, + final boolean ascendingOrder) { + Collections.sort(videoStreams, (o1, o2) -> { + int result = compareVideoStreamResolution(o1, o2); + return result == 0 ? 0 : (ascendingOrder ? result : -result); + }); + } + + /** + * Get the audio from the list with the highest quality. + * Format will be ignored if it yields no results. + * + * @param format The target format type or null if it doesn't matter + * @param audioStreams List of audio streams + * @return Index of audio stream that produces the most compact results or -1 if not found + */ + static int getHighestQualityAudioIndex(@Nullable MediaFormat format, + final List audioStreams) { + int result = -1; + if (audioStreams != null) { + while (result == -1) { + AudioStream prevStream = null; + for (int idx = 0; idx < audioStreams.size(); idx++) { + AudioStream stream = audioStreams.get(idx); + if ((format == null || stream.getFormat() == format) + && (prevStream == null || compareAudioStreamBitrate(prevStream, stream, + AUDIO_FORMAT_QUALITY_RANKING) < 0)) { + prevStream = stream; + result = idx; + } + } + if (result == -1 && format == null) { + break; + } + format = null; + } + } + return result; + } + + /** + * Get the audio from the list with the lowest bitrate and most efficient format. + * Format will be ignored if it yields no results. + * + * @param format The target format type or null if it doesn't matter + * @param audioStreams List of audio streams + * @return Index of audio stream that produces the most compact results or -1 if not found + */ + static int getMostCompactAudioIndex(@Nullable MediaFormat format, + final List audioStreams) { + int result = -1; + if (audioStreams != null) { + while (result == -1) { + AudioStream prevStream = null; + for (int idx = 0; idx < audioStreams.size(); idx++) { + AudioStream stream = audioStreams.get(idx); + if ((format == null || stream.getFormat() == format) + && (prevStream == null || compareAudioStreamBitrate(prevStream, stream, + AUDIO_FORMAT_EFFICIENCY_RANKING) > 0)) { + prevStream = stream; + result = idx; + } + } + if (result == -1 && format == null) { + break; + } + format = null; + } + } + return result; + } + + /** + * Locates a possible match for the given resolution and format in the provided list. + * + *

In this order:

+ * + *
    + *
  1. Find a format and resolution match
  2. + *
  3. Find a format and resolution match and ignore the refresh
  4. + *
  5. Find a resolution match
  6. + *
  7. Find a resolution match and ignore the refresh
  8. + *
  9. Find a resolution just below the requested resolution and ignore the refresh
  10. + *
  11. Give up
  12. + *
+ * + * @param targetResolution the resolution to look for + * @param targetFormat the format to look for + * @param videoStreams the available video streams + * @return the index of the prefered video stream + */ + static int getVideoStreamIndex(final String targetResolution, final MediaFormat targetFormat, + final List videoStreams) { + int fullMatchIndex = -1; + int fullMatchNoRefreshIndex = -1; + int resMatchOnlyIndex = -1; + int resMatchOnlyNoRefreshIndex = -1; + int lowerResMatchNoRefreshIndex = -1; + String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p"); + + for (int idx = 0; idx < videoStreams.size(); idx++) { + MediaFormat format = targetFormat == null ? null : videoStreams.get(idx).getFormat(); + String resolution = videoStreams.get(idx).getResolution(); + String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p"); + + if (format == targetFormat && resolution.equals(targetResolution)) { + fullMatchIndex = idx; + } + + if (format == targetFormat && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { + fullMatchNoRefreshIndex = idx; + } + + if (resMatchOnlyIndex == -1 && resolution.equals(targetResolution)) { + resMatchOnlyIndex = idx; + } + + if (resMatchOnlyNoRefreshIndex == -1 + && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { + resMatchOnlyNoRefreshIndex = idx; + } + + if (lowerResMatchNoRefreshIndex == -1 && compareVideoStreamResolution( + resolutionNoRefresh, targetResolutionNoRefresh) < 0) { + lowerResMatchNoRefreshIndex = idx; + } + } + + if (fullMatchIndex != -1) { + return fullMatchIndex; + } + if (fullMatchNoRefreshIndex != -1) { + return fullMatchNoRefreshIndex; + } + if (resMatchOnlyIndex != -1) { + return resMatchOnlyIndex; + } + if (resMatchOnlyNoRefreshIndex != -1) { + return resMatchOnlyNoRefreshIndex; + } + return lowerResMatchNoRefreshIndex; + } + + /** + * Fetches the desired resolution or returns the default if it is not found. + * The resolution will be reduced if video chocking is active. + * + * @param context Android app context + * @param defaultResolution the default resolution + * @param videoStreams the list of video streams to check + * @return the index of the prefered video stream + */ + private static int getDefaultResolutionWithDefaultFormat(final Context context, + final String defaultResolution, + final List videoStreams) { + MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_video_format_key, + R.string.default_video_format_value); + return getDefaultResolutionIndex(defaultResolution, + context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); + } + + private static MediaFormat getDefaultFormat(final Context context, + @StringRes final int defaultFormatKey, + @StringRes final int defaultFormatValueKey) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + + String defaultFormat = context.getString(defaultFormatValueKey); + String defaultFormatString = preferences.getString( + context.getString(defaultFormatKey), defaultFormat); + + MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString); + if (defaultMediaFormat == null) { + preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat) + .apply(); + defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat); + } + + return defaultMediaFormat; + } + + private static MediaFormat getMediaFormatFromKey(final Context context, + final String formatKey) { + MediaFormat format = null; + if (formatKey.equals(context.getString(R.string.video_webm_key))) { + format = MediaFormat.WEBM; + } else if (formatKey.equals(context.getString(R.string.video_mp4_key))) { + format = MediaFormat.MPEG_4; + } else if (formatKey.equals(context.getString(R.string.video_3gp_key))) { + format = MediaFormat.v3GPP; + } else if (formatKey.equals(context.getString(R.string.audio_webm_key))) { + format = MediaFormat.WEBMA; + } else if (formatKey.equals(context.getString(R.string.audio_m4a_key))) { + format = MediaFormat.M4A; + } + return format; + } + + // Compares the quality of two audio streams + private static int compareAudioStreamBitrate(final AudioStream streamA, + final AudioStream streamB, + final List formatRanking) { + if (streamA == null) { + return -1; + } + if (streamB == null) { + return 1; + } + if (streamA.getAverageBitrate() < streamB.getAverageBitrate()) { + return -1; + } + if (streamA.getAverageBitrate() > streamB.getAverageBitrate()) { + return 1; + } + + // Same bitrate and format + return formatRanking.indexOf(streamA.getFormat()) + - formatRanking.indexOf(streamB.getFormat()); + } + + private static int compareVideoStreamResolution(final String r1, final String r2) { + int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + return res1 - res2; + } + + // Compares the quality of two video streams. + private static int compareVideoStreamResolution(final VideoStream streamA, + final VideoStream streamB) { + if (streamA == null) { + return -1; + } + if (streamB == null) { + return 1; + } + + int resComp = compareVideoStreamResolution(streamA.getResolution(), + streamB.getResolution()); + if (resComp != 0) { + return resComp; + } + + // Same bitrate and format + return ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamA.getFormat()) + - ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamB.getFormat()); + } + + + private static boolean isLimitingDataUsage(final Context context) { + return getResolutionLimit(context) != null; + } + + /** + * The maximum resolution allowed. + * + * @param context App context + * @return maximum resolution allowed or null if there is no maximum + */ + private static String getResolutionLimit(final Context context) { + String resolutionLimit = null; + if (isMeteredNetwork(context)) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); + String defValue = context.getString(R.string.limit_data_usage_none_key); + String value = preferences.getString( + context.getString(R.string.limit_mobile_data_usage_key), defValue); + resolutionLimit = defValue.equals(value) ? null : value; + } + return resolutionLimit; + } + + /** + * The current network is metered (like mobile data)? + * + * @param context App context + * @return {@code true} if connected to a metered network + */ + private static boolean isMeteredNetwork(final Context context) { + ConnectivityManager manager + = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (manager == null || manager.getActiveNetworkInfo() == null) { + return false; + } + + return manager.isActiveNetworkMetered(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/Localization.java b/app/src/main/java/org/schabi/newpipelegacy/util/Localization.java new file mode 100644 index 000000000..6abf785cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/Localization.java @@ -0,0 +1,351 @@ +package org.schabi.newpipelegacy.util; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Build; +import android.preference.PreferenceManager; +import android.text.TextUtils; +import android.util.DisplayMetrics; + +import androidx.annotation.NonNull; +import androidx.annotation.PluralsRes; +import androidx.annotation.StringRes; + +import org.ocpsoft.prettytime.PrettyTime; +import org.ocpsoft.prettytime.units.Decade; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.localization.ContentCountry; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DateFormat; +import java.text.NumberFormat; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.List; +import java.util.Locale; + + +/* + * Created by chschtsch on 12/29/15. + * + * Copyright (C) Gregory Arkhipov 2015 + * Localization.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 final class Localization { + + private static final String DOT_SEPARATOR = " • "; + private static PrettyTime prettyTime; + + private Localization() { } + + public static void init(final Context context) { + initPrettyTime(context); + } + + @NonNull + public static String concatenateStrings(final String... strings) { + return concatenateStrings(Arrays.asList(strings)); + } + + @NonNull + public static String concatenateStrings(final List strings) { + if (strings.isEmpty()) { + return ""; + } + + final StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append(strings.get(0)); + + for (int i = 1; i < strings.size(); i++) { + final String string = strings.get(i); + if (!TextUtils.isEmpty(string)) { + stringBuilder.append(DOT_SEPARATOR).append(strings.get(i)); + } + } + + return stringBuilder.toString(); + } + + public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( + final Context context) { + final String contentLanguage = PreferenceManager + .getDefaultSharedPreferences(context) + .getString(context.getString(R.string.content_language_key), + context.getString(R.string.default_localization_key)); + if (contentLanguage.equals(context.getString(R.string.default_localization_key))) { + return org.schabi.newpipe.extractor.localization.Localization + .fromLocale(Locale.getDefault()); + } + return org.schabi.newpipe.extractor.localization.Localization + .fromLocalizationCode(contentLanguage); + } + + public static ContentCountry getPreferredContentCountry(final Context context) { + final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.content_country_key), + context.getString(R.string.default_localization_key)); + if (contentCountry.equals(context.getString(R.string.default_localization_key))) { + return new ContentCountry(Locale.getDefault().getCountry()); + } + return new ContentCountry(contentCountry); + } + + public static Locale getPreferredLocale(final Context context) { + SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + + String languageCode = sp.getString(context.getString(R.string.content_language_key), + context.getString(R.string.default_localization_key)); + + try { + if (languageCode.length() == 2) { + return new Locale(languageCode); + } else if (languageCode.contains("_")) { + String country = languageCode.substring(languageCode.indexOf("_")); + return new Locale(languageCode.substring(0, 2), country); + } + } catch (Exception ignored) { + } + + return Locale.getDefault(); + } + + public static String localizeNumber(final Context context, final long number) { + return localizeNumber(context, (double) number); + } + + public static String localizeNumber(final Context context, final double number) { + NumberFormat nf = NumberFormat.getInstance(getAppLocale(context)); + return nf.format(number); + } + + public static String formatDate(final Date date, final Context context) { + return DateFormat.getDateInstance(DateFormat.MEDIUM, getAppLocale(context)).format(date); + } + + @SuppressLint("StringFormatInvalid") + public static String localizeUploadDate(final Context context, final Date date) { + return context.getString(R.string.upload_date_text, formatDate(date, context)); + } + + public static String localizeViewCount(final Context context, final long viewCount) { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + localizeNumber(context, viewCount)); + } + + public static String localizeStreamCount(final Context context, final long streamCount) { + switch ((int) streamCount) { + case (int) ListExtractor.ITEM_COUNT_UNKNOWN: + return ""; + case (int) ListExtractor.ITEM_COUNT_INFINITE: + return context.getResources().getString(R.string.infinite_videos); + case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: + return context.getResources().getString(R.string.more_than_100_videos); + default: + return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, + localizeNumber(context, streamCount)); + } + } + + public static String localizeStreamCountMini(final Context context, final long streamCount) { + switch ((int) streamCount) { + case (int) ListExtractor.ITEM_COUNT_UNKNOWN: + return ""; + case (int) ListExtractor.ITEM_COUNT_INFINITE: + return context.getResources().getString(R.string.infinite_videos_mini); + case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: + return context.getResources().getString(R.string.more_than_100_videos_mini); + default: + return String.valueOf(streamCount); + } + } + + public static String localizeWatchingCount(final Context context, final long watchingCount) { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, + localizeNumber(context, watchingCount)); + } + + public static String shortCount(final Context context, final long count) { + double value = (double) count; + if (count >= 1000000000) { + return localizeNumber(context, round(value / 1000000000, 1)) + + context.getString(R.string.short_billion); + } else if (count >= 1000000) { + return localizeNumber(context, round(value / 1000000, 1)) + + context.getString(R.string.short_million); + } else if (count >= 1000) { + return localizeNumber(context, round(value / 1000, 1)) + + context.getString(R.string.short_thousand); + } else { + return localizeNumber(context, value); + } + } + + public static String listeningCount(final Context context, final long listeningCount) { + return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, + shortCount(context, listeningCount)); + } + + public static String shortWatchingCount(final Context context, final long watchingCount) { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, + shortCount(context, watchingCount)); + } + + public static String shortViewCount(final Context context, final long viewCount) { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + shortCount(context, viewCount)); + } + + public static String shortSubscriberCount(final Context context, final long subscriberCount) { + return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, + shortCount(context, subscriberCount)); + } + + private static String getQuantity(final Context context, @PluralsRes final int pluralId, + @StringRes final int zeroCaseStringId, final long count, + final String formattedCount) { + if (count == 0) { + return context.getString(zeroCaseStringId); + } + + // As we use the already formatted count + // is not the responsibility of this method handle long numbers + // (it probably will fall in the "other" category, + // or some language have some specific rule... then we have to change it) + int safeCount = count > Integer.MAX_VALUE ? Integer.MAX_VALUE : count < Integer.MIN_VALUE + ? Integer.MIN_VALUE : (int) count; + return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); + } + + public static String getDurationString(final long duration) { + final String output; + + final long days = duration / (24 * 60 * 60L); /* greater than a day */ + final long hours = duration % (24 * 60 * 60L) / (60 * 60L); /* greater than an hour */ + final long minutes = duration % (24 * 60 * 60L) % (60 * 60L) / 60L; + final long seconds = duration % 60L; + + if (duration < 0) { + output = "0:00"; + } else if (days > 0) { + //handle days + output = String.format(Locale.US, "%d:%02d:%02d:%02d", days, hours, minutes, seconds); + } else if (hours > 0) { + output = String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds); + } else { + output = String.format(Locale.US, "%d:%02d", minutes, seconds); + } + return output; + } + + /** + * Localize an amount of seconds into a human readable string. + * + *

The seconds will be converted to the closest whole time unit. + *

For example, 60 seconds would give "1 minute", 119 would also give "1 minute". + * + * @param context used to get plurals resources. + * @param durationInSecs an amount of seconds. + * @return duration in a human readable string. + */ + @NonNull + public static String localizeDuration(final Context context, final int durationInSecs) { + if (durationInSecs < 0) { + throw new IllegalArgumentException("duration can not be negative"); + } + + final int days = (int) (durationInSecs / (24 * 60 * 60L)); + final int hours = (int) (durationInSecs % (24 * 60 * 60L) / (60 * 60L)); + final int minutes = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) / 60L); + final int seconds = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) % 60L); + + final Resources resources = context.getResources(); + + if (days > 0) { + return resources.getQuantityString(R.plurals.days, days, days); + } else if (hours > 0) { + return resources.getQuantityString(R.plurals.hours, hours, hours); + } else if (minutes > 0) { + return resources.getQuantityString(R.plurals.minutes, minutes, minutes); + } else { + return resources.getQuantityString(R.plurals.seconds, seconds, seconds); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Pretty Time + //////////////////////////////////////////////////////////////////////////*/ + + private static void initPrettyTime(final Context context) { + prettyTime = new PrettyTime(getAppLocale(context)); + // Do not use decades as YouTube doesn't either. + prettyTime.removeUnit(Decade.class); + } + + private static PrettyTime getPrettyTime() { + return prettyTime; + } + + public static String relativeTime(final Calendar calendarTime) { + String time = getPrettyTime().formatUnrounded(calendarTime); + return time.startsWith("-") ? time.substring(1) : time; + //workaround fix for russian showing -1 day ago, -19hrs ago… + } + + private static void changeAppLanguage(final Locale loc, final Resources res) { + DisplayMetrics dm = res.getDisplayMetrics(); + Configuration conf = res.getConfiguration(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + conf.setLocale(loc); + } else { + conf.locale = loc; + } + res.updateConfiguration(conf, dm); + } + + public static Locale getAppLocale(final Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String lang = prefs.getString(context.getString(R.string.app_language_key), "en"); + Locale loc; + if (lang.equals(context.getString(R.string.default_localization_key))) { + loc = Locale.getDefault(); + } else if (lang.matches(".*-.*")) { + //to differentiate different versions of the language + //for example, pt (portuguese in Portugal) and pt-br (portuguese in Brazil) + String[] localisation = lang.split("-"); + lang = localisation[0]; + String country = localisation[1]; + loc = new Locale(lang, country); + } else { + loc = new Locale(lang); + } + return loc; + } + + public static void assureCorrectAppLanguage(final Context c) { + changeAppLanguage(getAppLocale(c), c.getResources()); + } + + private static double round(final double value, final int places) { + return new BigDecimal(value).setScale(places, RoundingMode.HALF_UP).doubleValue(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/NavigationHelper.java new file mode 100644 index 000000000..f04d6b2f4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/NavigationHelper.java @@ -0,0 +1,608 @@ +package org.schabi.newpipelegacy.util; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.preference.PreferenceManager; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; + +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipelegacy.MainActivity; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.RouterActivity; +import org.schabi.newpipelegacy.about.AboutActivity; +import org.schabi.newpipelegacy.database.feed.model.FeedGroupEntity; +import org.schabi.newpipelegacy.download.DownloadActivity; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.fragments.MainFragment; +import org.schabi.newpipelegacy.fragments.detail.VideoDetailFragment; +import org.schabi.newpipelegacy.fragments.list.channel.ChannelFragment; +import org.schabi.newpipelegacy.fragments.list.comments.CommentsFragment; +import org.schabi.newpipelegacy.fragments.list.kiosk.KioskFragment; +import org.schabi.newpipelegacy.fragments.list.playlist.PlaylistFragment; +import org.schabi.newpipelegacy.fragments.list.search.SearchFragment; +import org.schabi.newpipelegacy.local.bookmark.BookmarkFragment; +import org.schabi.newpipelegacy.local.feed.FeedFragment; +import org.schabi.newpipelegacy.local.history.StatisticsPlaylistFragment; +import org.schabi.newpipelegacy.local.playlist.LocalPlaylistFragment; +import org.schabi.newpipelegacy.local.subscription.SubscriptionFragment; +import org.schabi.newpipelegacy.local.subscription.SubscriptionsImportFragment; +import org.schabi.newpipelegacy.player.BackgroundPlayer; +import org.schabi.newpipelegacy.player.BackgroundPlayerActivity; +import org.schabi.newpipelegacy.player.BasePlayer; +import org.schabi.newpipelegacy.player.MainVideoPlayer; +import org.schabi.newpipelegacy.player.PopupVideoPlayer; +import org.schabi.newpipelegacy.player.PopupVideoPlayerActivity; +import org.schabi.newpipelegacy.player.VideoPlayer; +import org.schabi.newpipelegacy.player.playqueue.PlayQueue; +import org.schabi.newpipelegacy.settings.SettingsActivity; + +import java.util.ArrayList; + +@SuppressWarnings({"unused", "WeakerAccess"}) +public final class NavigationHelper { + public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; + public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; + + private NavigationHelper() { } + + /*////////////////////////////////////////////////////////////////////////// + // Players + //////////////////////////////////////////////////////////////////////////*/ + + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + @Nullable final String quality, + final boolean resumePlayback) { + Intent intent = new Intent(context, targetClazz); + + final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); + if (cacheKey != null) { + intent.putExtra(VideoPlayer.PLAY_QUEUE_KEY, cacheKey); + } + if (quality != null) { + intent.putExtra(VideoPlayer.PLAYBACK_QUALITY, quality); + } + intent.putExtra(VideoPlayer.RESUME_PLAYBACK, resumePlayback); + + return intent; + } + + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + final boolean resumePlayback) { + return getPlayerIntent(context, targetClazz, playQueue, null, resumePlayback); + } + + @NonNull + public static Intent getPlayerEnqueueIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + final boolean selectOnAppend, + final boolean resumePlayback) { + return getPlayerIntent(context, targetClazz, playQueue, resumePlayback) + .putExtra(BasePlayer.APPEND_ONLY, true) + .putExtra(BasePlayer.SELECT_ON_APPEND, selectOnAppend); + } + + @NonNull + public static Intent getPlayerIntent(@NonNull final Context context, + @NonNull final Class targetClazz, + @NonNull final PlayQueue playQueue, + final int repeatMode, final float playbackSpeed, + final float playbackPitch, + final boolean playbackSkipSilence, + @Nullable final String playbackQuality, + final boolean resumePlayback, final boolean startPaused, + final boolean isMuted) { + return getPlayerIntent(context, targetClazz, playQueue, playbackQuality, resumePlayback) + .putExtra(BasePlayer.REPEAT_MODE, repeatMode) + .putExtra(BasePlayer.START_PAUSED, startPaused) + .putExtra(BasePlayer.IS_MUTED, isMuted); + } + + public static void playOnMainPlayer(final Context context, final PlayQueue queue, + final boolean resumePlayback) { + final Intent playerIntent + = getPlayerIntent(context, MainVideoPlayer.class, queue, resumePlayback); + playerIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(playerIntent); + } + + public static void playOnPopupPlayer(final Context context, final PlayQueue queue, + final boolean resumePlayback) { + if (!PermissionHelper.isPopupEnabled(context)) { + PermissionHelper.showPopupEnablementToast(context); + return; + } + + Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); + startService(context, + getPlayerIntent(context, PopupVideoPlayer.class, queue, resumePlayback)); + } + + public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue, + final boolean resumePlayback) { + Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) + .show(); + startService(context, + getPlayerIntent(context, BackgroundPlayer.class, queue, resumePlayback)); + } + + public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue, + final boolean resumePlayback) { + enqueueOnPopupPlayer(context, queue, false, resumePlayback); + } + + public static void enqueueOnPopupPlayer(final Context context, final PlayQueue queue, + final boolean selectOnAppend, + final boolean resumePlayback) { + if (!PermissionHelper.isPopupEnabled(context)) { + PermissionHelper.showPopupEnablementToast(context); + return; + } + + Toast.makeText(context, R.string.popup_playing_append, Toast.LENGTH_SHORT).show(); + startService(context, getPlayerEnqueueIntent(context, PopupVideoPlayer.class, queue, + selectOnAppend, resumePlayback)); + } + + public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, + final boolean resumePlayback) { + enqueueOnBackgroundPlayer(context, queue, false, resumePlayback); + } + + public static void enqueueOnBackgroundPlayer(final Context context, final PlayQueue queue, + final boolean selectOnAppend, + final boolean resumePlayback) { + Toast.makeText(context, R.string.background_player_append, Toast.LENGTH_SHORT).show(); + startService(context, getPlayerEnqueueIntent(context, BackgroundPlayer.class, queue, + selectOnAppend, resumePlayback)); + } + + public static void startService(@NonNull final Context context, @NonNull final Intent intent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + /*////////////////////////////////////////////////////////////////////////// + // External Players + //////////////////////////////////////////////////////////////////////////*/ + + public static void playOnExternalAudioPlayer(final Context context, final StreamInfo info) { + final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); + + if (index == -1) { + Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); + return; + } + + AudioStream audioStream = info.getAudioStreams().get(index); + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); + } + + public static void playOnExternalVideoPlayer(final Context context, final 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); + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); + } + + public static void playOnExternalPlayer(final Context context, final String name, + final String artist, final Stream stream) { + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType()); + intent.putExtra(Intent.EXTRA_TITLE, name); + intent.putExtra("title", name); + intent.putExtra("artist", artist); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + resolveActivityOrAskToInstall(context, intent); + } + + public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { + if (intent.resolveActivity(context.getPackageManager()) != null) { + context.startActivity(intent); + } else { + if (context instanceof Activity) { + new AlertDialog.Builder(context) + .setMessage(R.string.no_player_found) + .setPositiveButton(R.string.install, (dialog, which) -> { + Intent i = new Intent(); + i.setAction(Intent.ACTION_VIEW); + i.setData(Uri.parse(context.getString(R.string.fdroid_vlc_url))); + context.startActivity(i); + }) + .setNegativeButton(R.string.cancel, (dialog, which) + -> Log.i("NavigationHelper", "You unlocked a secret unicorn.")) + .show(); +// Log.e("NavigationHelper", +// "Either no Streaming player for audio was installed, " +// + "or something important crashed:"); + } else { + Toast.makeText(context, R.string.no_player_found_toast, Toast.LENGTH_LONG).show(); + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Through FragmentManager + //////////////////////////////////////////////////////////////////////////*/ + + @SuppressLint("CommitTransaction") + private static FragmentTransaction defaultTransaction(final FragmentManager fragmentManager) { + return fragmentManager.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, + R.animator.custom_fade_in, R.animator.custom_fade_out); + } + + public static void gotoMainFragment(final FragmentManager fragmentManager) { + ImageLoader.getInstance().clearMemoryCache(); + + boolean popped = fragmentManager.popBackStackImmediate(MAIN_FRAGMENT_TAG, 0); + if (!popped) { + openMainFragment(fragmentManager); + } + } + + public static void openMainFragment(final FragmentManager fragmentManager) { + InfoCache.getInstance().trimCache(); + + fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new MainFragment()) + .addToBackStack(MAIN_FRAGMENT_TAG) + .commit(); + } + + public static boolean tryGotoSearchFragment(final FragmentManager fragmentManager) { + if (MainActivity.DEBUG) { + for (int i = 0; i < fragmentManager.getBackStackEntryCount(); i++) { + Log.d("NavigationHelper", "tryGoToSearchFragment() [" + i + "]" + + " = [" + fragmentManager.getBackStackEntryAt(i) + "]"); + } + } + + return fragmentManager.popBackStackImmediate(SEARCH_FRAGMENT_TAG, 0); + } + + public static void openSearchFragment(final FragmentManager fragmentManager, + final int serviceId, final String searchString) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, searchString)) + .addToBackStack(SEARCH_FRAGMENT_TAG) + .commit(); + } + + public static void openVideoDetailFragment(final FragmentManager fragmentManager, + final int serviceId, final String url, + final String title) { + openVideoDetailFragment(fragmentManager, serviceId, url, title, false); + } + + public static void openVideoDetailFragment(final FragmentManager fragmentManager, + final int serviceId, final String url, + final String name, final boolean autoPlay) { + Fragment fragment = fragmentManager.findFragmentById(R.id.fragment_holder); + + if (fragment instanceof VideoDetailFragment && fragment.isVisible()) { + VideoDetailFragment detailFragment = (VideoDetailFragment) fragment; + detailFragment.setAutoplay(autoPlay); + detailFragment.selectAndLoadVideo(serviceId, url, name == null ? "" : name); + return; + } + + VideoDetailFragment instance = VideoDetailFragment.getInstance(serviceId, url, + name == null ? "" : name); + instance.setAutoplay(autoPlay); + + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, instance) + .addToBackStack(null) + .commit(); + } + + public static void openChannelFragment(final FragmentManager fragmentManager, + final int serviceId, final String url, + final String name) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, + name == null ? "" : name)) + .addToBackStack(null) + .commit(); + } + + public static void openCommentsFragment(final FragmentManager fragmentManager, + final int serviceId, final String url, + final String name) { + fragmentManager.beginTransaction() + .setCustomAnimations(R.anim.switch_service_in, R.anim.switch_service_out) + .replace(R.id.fragment_holder, CommentsFragment.getInstance(serviceId, url, + name == null ? "" : name)) + .addToBackStack(null) + .commit(); + } + + public static void openPlaylistFragment(final FragmentManager fragmentManager, + final int serviceId, final String url, + final String name) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, + name == null ? "" : name)) + .addToBackStack(null) + .commit(); + } + + public static void openFeedFragment(final FragmentManager fragmentManager) { + openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null); + } + + public static void openFeedFragment(final FragmentManager fragmentManager, final long groupId, + @Nullable final String groupName) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) + .addToBackStack(null) + .commit(); + } + + public static void openBookmarksFragment(final FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new BookmarkFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openSubscriptionFragment(final FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new SubscriptionFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openKioskFragment(final FragmentManager fragmentManager, final int serviceId, + final String kioskId) throws ExtractionException { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId)) + .addToBackStack(null) + .commit(); + } + + public static void openLocalPlaylistFragment(final FragmentManager fragmentManager, + final long playlistId, final String name) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, + name == null ? "" : name)) + .addToBackStack(null) + .commit(); + } + + public static void openStatisticFragment(final FragmentManager fragmentManager) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, new StatisticsPlaylistFragment()) + .addToBackStack(null) + .commit(); + } + + public static void openSubscriptionsImportFragment(final FragmentManager fragmentManager, + final int serviceId) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId)) + .addToBackStack(null) + .commit(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Through Intents + //////////////////////////////////////////////////////////////////////////*/ + + public static void openSearch(final Context context, final int serviceId, + final String searchString) { + Intent mIntent = new Intent(context, MainActivity.class); + mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); + mIntent.putExtra(Constants.KEY_SEARCH_STRING, searchString); + mIntent.putExtra(Constants.KEY_OPEN_SEARCH, true); + context.startActivity(mIntent); + } + + public static void openChannel(final Context context, final int serviceId, final String url) { + openChannel(context, serviceId, url, null); + } + + public static void openChannel(final Context context, final int serviceId, + final String url, final String name) { + Intent openIntent = getOpenIntent(context, url, serviceId, + StreamingService.LinkType.CHANNEL); + if (name != null && !name.isEmpty()) { + openIntent.putExtra(Constants.KEY_TITLE, name); + } + context.startActivity(openIntent); + } + + public static void openVideoDetail(final Context context, final int serviceId, + final String url) { + openVideoDetail(context, serviceId, url, null); + } + + public static void openVideoDetail(final Context context, final int serviceId, + final String url, final String title) { + Intent openIntent = getOpenIntent(context, url, serviceId, + StreamingService.LinkType.STREAM); + if (title != null && !title.isEmpty()) { + openIntent.putExtra(Constants.KEY_TITLE, title); + } + context.startActivity(openIntent); + } + + public static void openMainActivity(final Context context) { + Intent mIntent = new Intent(context, MainActivity.class); + mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(mIntent); + } + + public static void openRouterActivity(final Context context, final String url) { + Intent mIntent = new Intent(context, RouterActivity.class); + mIntent.setData(Uri.parse(url)); + mIntent.putExtra(RouterActivity.INTERNAL_ROUTE_KEY, true); + context.startActivity(mIntent); + } + + public static void openAbout(final Context context) { + Intent intent = new Intent(context, AboutActivity.class); + context.startActivity(intent); + } + + public static void openSettings(final Context context) { + Intent intent = new Intent(context, SettingsActivity.class); + context.startActivity(intent); + } + + public static boolean openDownloads(final Activity activity) { + if (!PermissionHelper.checkStoragePermissions( + activity, PermissionHelper.DOWNLOADS_REQUEST_CODE)) { + return false; + } + Intent intent = new Intent(activity, DownloadActivity.class); + activity.startActivity(intent); + return true; + } + + public static Intent getBackgroundPlayerActivityIntent(final Context context) { + return getServicePlayerActivityIntent(context, BackgroundPlayerActivity.class); + } + + public static Intent getPopupPlayerActivityIntent(final Context context) { + return getServicePlayerActivityIntent(context, PopupVideoPlayerActivity.class); + } + + private static Intent getServicePlayerActivityIntent(final Context context, + final Class activityClass) { + Intent intent = new Intent(context, activityClass); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + return intent; + } + /*////////////////////////////////////////////////////////////////////////// + // Link handling + //////////////////////////////////////////////////////////////////////////*/ + + private static Intent getOpenIntent(final Context context, final String url, + final int serviceId, final StreamingService.LinkType type) { + Intent mIntent = new Intent(context, MainActivity.class); + mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); + mIntent.putExtra(Constants.KEY_URL, url); + mIntent.putExtra(Constants.KEY_LINK_TYPE, type); + return mIntent; + } + + public static Intent getIntentByLink(final Context context, final String url) + throws ExtractionException { + return getIntentByLink(context, NewPipe.getServiceByUrl(url), url); + } + + public static Intent getIntentByLink(final Context context, final StreamingService service, + final String url) throws ExtractionException { + StreamingService.LinkType linkType = service.getLinkTypeByUrl(url); + + if (linkType == StreamingService.LinkType.NONE) { + throw new ExtractionException("Url not known to service. service=" + service + + " url=" + url); + } + + Intent rIntent = getOpenIntent(context, url, service.getServiceId(), linkType); + + if (linkType == StreamingService.LinkType.STREAM) { + rIntent.putExtra(VideoDetailFragment.AUTO_PLAY, + PreferenceManager.getDefaultSharedPreferences(context).getBoolean( + context.getString(R.string.autoplay_through_intent_key), false)); + } + + return rIntent; + } + + private static Uri openMarketUrl(final String packageName) { + return Uri.parse("market://details") + .buildUpon() + .appendQueryParameter("id", packageName) + .build(); + } + + private static Uri getGooglePlayUrl(final String packageName) { + return Uri.parse("https://play.google.com/store/apps/details") + .buildUpon() + .appendQueryParameter("id", packageName) + .build(); + } + + private static void installApp(final Context context, final String packageName) { + try { + // Try market:// scheme + context.startActivity(new Intent(Intent.ACTION_VIEW, openMarketUrl(packageName))); + } catch (ActivityNotFoundException e) { + // Fall back to google play URL (don't worry F-Droid can handle it :) + context.startActivity(new Intent(Intent.ACTION_VIEW, getGooglePlayUrl(packageName))); + } + } + + /** + * Start an activity to install Kore. + * + * @param context the context + */ + public static void installKore(final Context context) { + installApp(context, context.getString(R.string.kore_package)); + } + + /** + * Start Kore app to show a video on Kodi. + *

+ * For a list of supported urls see the + * + * Kore source code + * . + * + * @param context the context to use + * @param videoURL the url to the video + */ + public static void playWithKore(final Context context, final Uri videoURL) { + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setPackage(context.getString(R.string.kore_package)); + intent.setData(videoURL); + context.startActivity(intent); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipelegacy/util/OnClickGesture.java new file mode 100644 index 000000000..8a9a808b8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/OnClickGesture.java @@ -0,0 +1,16 @@ +package org.schabi.newpipelegacy.util; + +import androidx.recyclerview.widget.RecyclerView; + +public abstract class OnClickGesture { + + public abstract void selected(T selectedItem); + + public void held(final T selectedItem) { + // Optional gesture + } + + public void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) { + // Optional gesture + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/PeertubeHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/PeertubeHelper.java new file mode 100644 index 000000000..23246977d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/PeertubeHelper.java @@ -0,0 +1,69 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class PeertubeHelper { + private PeertubeHelper() { } + + public static List getInstanceList(final Context context) { + SharedPreferences sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context); + String savedInstanceListKey = context.getString(R.string.peertube_instance_list_key); + final String savedJson = sharedPreferences.getString(savedInstanceListKey, null); + if (null == savedJson) { + return Collections.singletonList(getCurrentInstance()); + } + + try { + JsonArray array = JsonParser.object().from(savedJson).getArray("instances"); + List result = new ArrayList<>(); + for (Object o : array) { + if (o instanceof JsonObject) { + JsonObject instance = (JsonObject) o; + String name = instance.getString("name"); + String url = instance.getString("url"); + result.add(new PeertubeInstance(url, name)); + } + } + return result; + } catch (JsonParserException e) { + return Collections.singletonList(getCurrentInstance()); + } + + } + + public static PeertubeInstance selectInstance(final PeertubeInstance instance, + final Context context) { + SharedPreferences sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context); + String selectedInstanceKey = context.getString(R.string.peertube_selected_instance_key); + JsonStringWriter jsonWriter = JsonWriter.string().object(); + jsonWriter.value("name", instance.getName()); + jsonWriter.value("url", instance.getUrl()); + String jsonToSave = jsonWriter.end().done(); + sharedPreferences.edit().putString(selectedInstanceKey, jsonToSave).apply(); + ServiceList.PeerTube.setInstance(instance); + return instance; + } + + public static PeertubeInstance getCurrentInstance() { + return ServiceList.PeerTube.getInstance(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/PermissionHelper.java new file mode 100644 index 000000000..314b32e16 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/PermissionHelper.java @@ -0,0 +1,130 @@ +package org.schabi.newpipelegacy.util; + +import android.Manifest; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.provider.Settings; +import android.view.Gravity; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; + +public final class PermissionHelper { + public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; + public static final int DOWNLOADS_REQUEST_CODE = 777; + + private PermissionHelper() { } + + public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + if (!checkReadStoragePermissions(activity, requestCode)) { + return false; + } + } + return checkWriteStoragePermissions(activity, requestCode); + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) + public static boolean checkReadStoragePermissions(final Activity activity, + final int requestCode) { + if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, + new String[]{ + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE}, + requestCode); + + return false; + } + return true; + } + + + public static boolean checkWriteStoragePermissions(final Activity activity, + final int requestCode) { + // Here, thisActivity is the current activity + if (ContextCompat.checkSelfPermission(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + + // Should we show an explanation? + /*if (ActivityCompat.shouldShowRequestPermissionRationale(activity, + Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + + // Show an explanation to the user *asynchronously* -- don't block + // this thread waiting for the user's response! After the user + // sees the explanation, try again to request the permission. + } else {*/ + + // No explanation needed, we can request the permission. + ActivityCompat.requestPermissions(activity, + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, + requestCode); + + // PERMISSION_WRITE_STORAGE is an + // app-defined int constant. The callback method gets the + // result of the request. + /*}*/ + return false; + } + return true; + } + + + /** + * In order to be able to draw over other apps, + * the permission android.permission.SYSTEM_ALERT_WINDOW have to be granted. + *

+ * On < API 23 (MarshMallow) the permission was granted + * when the user installed the application (via AndroidManifest), + * on > 23, however, it have to start a activity asking the user if he agrees. + *

+ *

+ * This method just return if the app has permission to draw over other apps, + * and if it doesn't, it will try to get the permission. + *

+ * + * @param context {@link Context} + * @return {@link Settings#canDrawOverlays(Context)} + **/ + @RequiresApi(api = Build.VERSION_CODES.M) + public static boolean checkSystemAlertWindowPermission(final Context context) { + if (!Settings.canDrawOverlays(context)) { + Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + context.getPackageName())); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(i); + } catch (ActivityNotFoundException ignored) { + } + return false; + } else { + return true; + } + } + + public static boolean isPopupEnabled(final Context context) { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || PermissionHelper.checkSystemAlertWindowPermission(context); + } + + public static void showPopupEnablementToast(final Context context) { + Toast toast = Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG); + TextView messageView = toast.getView().findViewById(android.R.id.message); + if (messageView != null) { + messageView.setGravity(Gravity.CENTER); + } + toast.show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/RelatedStreamInfo.java b/app/src/main/java/org/schabi/newpipelegacy/util/RelatedStreamInfo.java new file mode 100644 index 000000000..83c9aa500 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/RelatedStreamInfo.java @@ -0,0 +1,28 @@ +package org.schabi.newpipelegacy.util; + +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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class RelatedStreamInfo extends ListInfo { + public RelatedStreamInfo(final int serviceId, final ListLinkHandler listUrlIdHandler, + final String name) { + super(serviceId, listUrlIdHandler, name); + } + + public static RelatedStreamInfo getInfo(final StreamInfo info) { + ListLinkHandler handler = new ListLinkHandler( + info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null); + RelatedStreamInfo relatedStreamInfo = new RelatedStreamInfo( + info.getServiceId(), handler, info.getName()); + List streams = new ArrayList<>(); + streams.addAll(info.getRelatedStreams()); + relatedStreamInfo.setRelatedItems(streams); + return relatedStreamInfo; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/SecondaryStreamHelper.java new file mode 100644 index 000000000..8dc1f9d78 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/SecondaryStreamHelper.java @@ -0,0 +1,72 @@ +package org.schabi.newpipelegacy.util; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipelegacy.util.StreamItemAdapter.StreamSizeWrapper; + +import java.util.List; + +public class SecondaryStreamHelper { + private final int position; + private final StreamSizeWrapper streams; + + public SecondaryStreamHelper(final StreamSizeWrapper streams, final T selectedStream) { + this.streams = streams; + this.position = streams.getStreamsList().indexOf(selectedStream); + if (this.position < 0) { + throw new RuntimeException("selected stream not found"); + } + } + + /** + * 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 final List audioStreams, + @NonNull final VideoStream videoStream) { + switch (videoStream.getFormat()) { + case WEBM: + case MPEG_4:// ¿is mpeg-4 DASH? + 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; + } + } + + if (m4v) { + return null; + } + + // retry, but this time in reverse order + for (int i = audioStreams.size() - 1; i >= 0; i--) { + AudioStream audio = audioStreams.get(i); + if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { + return audio; + } + } + + return null; + } + + public T getStream() { + return streams.getStreamsList().get(position); + } + + public long getSizeInBytes() { + return streams.getSizeInBytes(position); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipelegacy/util/SerializedCache.java new file mode 100644 index 000000000..579818eb5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/SerializedCache.java @@ -0,0 +1,120 @@ +package org.schabi.newpipelegacy.util; + +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.LruCache; + +import org.schabi.newpipelegacy.MainActivity; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.UUID; + +public final class SerializedCache { + private static final boolean DEBUG = MainActivity.DEBUG; + private static final SerializedCache INSTANCE = new SerializedCache(); + private static final int MAX_ITEMS_ON_CACHE = 5; + private static final LruCache LRU_CACHE = + new LruCache<>(MAX_ITEMS_ON_CACHE); + private static final String TAG = "SerializedCache"; + + private SerializedCache() { + //no instance + } + + public static SerializedCache getInstance() { + return INSTANCE; + } + + @Nullable + public T take(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) { + Log.d(TAG, "take() called with: key = [" + key + "]"); + } + synchronized (LRU_CACHE) { + return LRU_CACHE.get(key) != null ? getItem(LRU_CACHE.remove(key), type) : null; + } + } + + @Nullable + public T get(@NonNull final String key, @NonNull final Class type) { + if (DEBUG) { + Log.d(TAG, "get() called with: key = [" + key + "]"); + } + synchronized (LRU_CACHE) { + final CacheData data = LRU_CACHE.get(key); + return data != null ? getItem(data, type) : null; + } + } + + @Nullable + public String put(@NonNull final T item, + @NonNull final Class type) { + final String key = UUID.randomUUID().toString(); + return put(key, item, type) ? key : null; + } + + public boolean put(@NonNull final String key, @NonNull final T item, + @NonNull final Class type) { + if (DEBUG) { + Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]"); + } + synchronized (LRU_CACHE) { + try { + LRU_CACHE.put(key, new CacheData<>(clone(item, type), type)); + return true; + } catch (final Exception error) { + Log.e(TAG, "Serialization failed for: ", error); + } + } + return false; + } + + public void clear() { + if (DEBUG) { + Log.d(TAG, "clear() called"); + } + synchronized (LRU_CACHE) { + LRU_CACHE.evictAll(); + } + } + + public long size() { + synchronized (LRU_CACHE) { + return LRU_CACHE.size(); + } + } + + @Nullable + private T getItem(@NonNull final CacheData data, @NonNull final Class type) { + return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; + } + + @NonNull + private T clone(@NonNull final T item, + @NonNull final Class type) throws Exception { + final ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream(); + try (ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput)) { + objectOutput.writeObject(item); + objectOutput.flush(); + } + final Object clone = new ObjectInputStream( + new ByteArrayInputStream(bytesOutput.toByteArray())).readObject(); + return type.cast(clone); + } + + private static final class CacheData { + private final T item; + private final Class type; + + private CacheData(@NonNull final T item, @NonNull final Class type) { + this.item = item; + this.type = type; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/ServiceHelper.java new file mode 100644 index 000000000..b2b32c2d5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ServiceHelper.java @@ -0,0 +1,199 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; + +import java.util.concurrent.TimeUnit; + +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + +public final class ServiceHelper { + private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; + + private ServiceHelper() { } + + @DrawableRes + public static int getIcon(final int serviceId) { + switch (serviceId) { + case 0: + return R.drawable.place_holder_youtube; + case 1: + return R.drawable.place_holder_cloud; + case 2: + return R.drawable.place_holder_gadse; + case 3: + return R.drawable.place_holder_peertube; + default: + return R.drawable.place_holder_circle; + } + } + + public static String getTranslatedFilterString(final String filter, final Context c) { + switch (filter) { + case "all": + return c.getString(R.string.all); + case "videos": + case "music_videos": + return c.getString(R.string.videos_string); + case "channels": + return c.getString(R.string.channels); + case "playlists": + case "music_playlists": + return c.getString(R.string.playlists); + case "tracks": + return c.getString(R.string.tracks); + case "users": + return c.getString(R.string.users); + case "conferences": + return c.getString(R.string.conferences); + case "events": + return c.getString(R.string.events); + case "music_songs": + return c.getString(R.string.songs); + case "music_albums": + return c.getString(R.string.albums); + case "music_artists": + return c.getString(R.string.artists); + default: + return filter; + } + } + + /** + * Get a resource string with instructions for importing subscriptions for each service. + * + * @param serviceId service to get the instructions for + * @return the string resource containing the instructions or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructions(final int serviceId) { + switch (serviceId) { + case 0: + return R.string.import_youtube_instructions; + case 1: + return R.string.import_soundcloud_instructions; + default: + return -1; + } + } + + /** + * For services that support importing from a channel url, return a hint that will + * be used in the EditText that the user will type in his channel url. + * + * @param serviceId service to get the hint for + * @return the hint's string resource or -1 if the service don't support it + */ + @StringRes + public static int getImportInstructionsHint(final int serviceId) { + switch (serviceId) { + case 1: + return R.string.import_soundcloud_instructions_hint; + default: + return -1; + } + } + + public static int getSelectedServiceId(final Context context) { + final String serviceName = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.current_service_key), + context.getString(R.string.default_service_value)); + + int serviceId; + try { + serviceId = NewPipe.getService(serviceName).getServiceId(); + } catch (ExtractionException e) { + serviceId = DEFAULT_FALLBACK_SERVICE.getServiceId(); + } + + return serviceId; + } + + public static void setSelectedServiceId(final Context context, final int serviceId) { + String serviceName; + try { + serviceName = NewPipe.getService(serviceId).getServiceInfo().getName(); + } catch (ExtractionException e) { + serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName(); + } + + setSelectedServicePreferences(context, serviceName); + } + + public static void setSelectedServiceId(final Context context, final String serviceName) { + int serviceId = NewPipe.getIdOfService(serviceName); + if (serviceId == -1) { + setSelectedServicePreferences(context, + DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName()); + } else { + setSelectedServicePreferences(context, serviceName); + } + } + + private static void setSelectedServicePreferences(final Context context, + final String serviceName) { + PreferenceManager.getDefaultSharedPreferences(context).edit(). + putString(context.getString(R.string.current_service_key), serviceName).apply(); + } + + public static long getCacheExpirationMillis(final int serviceId) { + if (serviceId == SoundCloud.getServiceId()) { + return TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES); + } else { + return TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); + } + } + + public static boolean isBeta(final StreamingService s) { + switch (s.getServiceInfo().getName()) { + case "YouTube": + return false; + default: + return true; + } + } + + public static void initService(final Context context, final int serviceId) { + if (serviceId == ServiceList.PeerTube.getServiceId()) { + SharedPreferences sharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context); + String json = sharedPreferences.getString(context.getString( + R.string.peertube_selected_instance_key), null); + if (null == json) { + return; + } + + JsonObject jsonObject = null; + try { + jsonObject = JsonParser.object().from(json); + } catch (JsonParserException e) { + return; + } + String name = jsonObject.getString("name"); + String url = jsonObject.getString("url"); + PeertubeInstance instance = new PeertubeInstance(url, name); + ServiceList.PeerTube.setInstance(instance); + } + } + + public static void initServices(final Context context) { + for (StreamingService s : ServiceList.all()) { + initService(context, s.getServiceId()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ShareUtils.java b/app/src/main/java/org/schabi/newpipelegacy/util/ShareUtils.java new file mode 100644 index 000000000..aa5b53cf4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ShareUtils.java @@ -0,0 +1,107 @@ +package org.schabi.newpipelegacy.util; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.widget.Toast; + +import org.schabi.newpipelegacy.R; + +public final class ShareUtils { + private ShareUtils() { + } + + /** + * Open the url with the system default browser. + *

+ * If no browser is set as default, fallbacks to + * {@link ShareUtils#openInDefaultApp(Context, String)} + * + * @param context the context to use + * @param url the url to browse + */ + public static void openUrlInBrowser(final Context context, final String url) { + final String defaultBrowserPackageName = getDefaultBrowserPackageName(context); + + if (defaultBrowserPackageName.equals("android")) { + // no browser set as default + openInDefaultApp(context, url); + } else { + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + intent.setPackage(defaultBrowserPackageName); + context.startActivity(intent); + } + } + + /** + * Open the url in the default app set to open this type of link. + *

+ * If no app is set as default, it will open a chooser + * + * @param context the context to use + * @param url the url to browse + */ + private static void openInDefaultApp(final Context context, final String url) { + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + context.startActivity(Intent.createChooser( + intent, context.getString(R.string.share_dialog_title))); + } + + /** + * Get the default browser package name. + *

+ * If no browser is set as default, it will return "android" + * + * @param context the context to use + * @return the package name of the default browser, or "android" if there's no default + */ + private static String getDefaultBrowserPackageName(final Context context) { + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); + final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity( + intent, PackageManager.MATCH_DEFAULT_ONLY); + return resolveInfo.activityInfo.packageName; + } + + /** + * Open the android share menu to share the current url. + * + * @param context the context to use + * @param subject the url subject, typically the title + * @param url the url to share + */ + public static void shareUrl(final Context context, final String subject, final String url) { + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_SUBJECT, subject); + intent.putExtra(Intent.EXTRA_TEXT, url); + context.startActivity(Intent.createChooser( + intent, context.getString(R.string.share_dialog_title))); + } + + /** + * Copy the text to clipboard, and indicate to the user whether the operation was completed + * successfully using a Toast. + * + * @param context the context to use + * @param text the text to copy + */ + public static void copyToClipboard(final Context context, final String text) { + final ClipboardManager clipboardManager = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (clipboardManager == null) { + Toast.makeText(context, + R.string.permission_denied, + Toast.LENGTH_LONG).show(); + return; + } + + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT) + .show(); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/SliderStrategy.java b/app/src/main/java/org/schabi/newpipelegacy/util/SliderStrategy.java new file mode 100644 index 000000000..72afaf8ad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/SliderStrategy.java @@ -0,0 +1,79 @@ +package org.schabi.newpipelegacy.util; + +public interface SliderStrategy { + /** + * Converts from zeroed double with a minimum offset to the nearest rounded slider + * equivalent integer. + * + * @param value the value to convert + * @return the converted value + */ + int progressOf(double value); + + /** + * Converts from slider integer value to an equivalent double value with a given + * minimum offset. + * + * @param progress the value to convert + * @return the converted value + */ + double valueOf(int progress); + + // TODO: also implement linear strategy when needed + + final class Quadratic implements SliderStrategy { + private final double leftGap; + private final double rightGap; + private final double center; + + private final int centerProgress; + + /** + * Quadratic slider strategy that scales the value of a slider given how far the slider + * progress is from the center of the slider. The further away from the center, + * the faster the interpreted value changes, and vice versa. + * + * @param minimum the minimum value of the interpreted value of the slider. + * @param maximum the maximum value of the interpreted value of the slider. + * @param center center of the interpreted value between the minimum and maximum, which + * will be used as the center value on the slider progress. Doesn't need + * to be the average of the minimum and maximum values, but must be in + * between the two. + * @param maxProgress the maximum possible progress of the slider, this is the + * value that is shown for the UI and controls the granularity of + * the slider. Should be as large as possible to avoid floating + * point round-off error. Using odd number is recommended. + */ + public Quadratic(final double minimum, final double maximum, final double center, + final int maxProgress) { + if (center < minimum || center > maximum) { + throw new IllegalArgumentException("Center must be in between minimum and maximum"); + } + + this.leftGap = minimum - center; + this.rightGap = maximum - center; + this.center = center; + + this.centerProgress = maxProgress / 2; + } + + @Override + public int progressOf(final double value) { + final double difference = value - center; + final double root = difference >= 0 ? Math.sqrt(difference / rightGap) + : -Math.sqrt(Math.abs(difference / leftGap)); + final double offset = Math.round(root * centerProgress); + + return (int) (centerProgress + offset); + } + + @Override + public double valueOf(final int progress) { + final int offset = progress - centerProgress; + final double square = Math.pow(((double) offset) / ((double) centerProgress), 2); + final double difference = square * (offset >= 0 ? rightGap : leftGap); + + return difference + center; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/StateSaver.java b/app/src/main/java/org/schabi/newpipelegacy/util/StateSaver.java new file mode 100644 index 000000000..8f9210feb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/StateSaver.java @@ -0,0 +1,427 @@ +/* + * Copyright 2017 Mauricio Colli + * StateSaver.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.util; + + +import android.content.Context; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.MainActivity; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FilenameFilter; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.LinkedList; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A way to save state to disk or in a in-memory map + * if it's just changing configurations (i.e. rotating the phone). + */ +public final class StateSaver { + public static final String KEY_SAVED_STATE = "key_saved_state"; + private static final ConcurrentHashMap> STATE_OBJECTS_HOLDER + = new ConcurrentHashMap<>(); + private static final String TAG = "StateSaver"; + private static final String CACHE_DIR_NAME = "state_cache"; + private static String cacheDirPath; + + private StateSaver() { + //no instance + } + + /** + * Initialize the StateSaver, usually you want to call this in the Application class. + * + * @param context used to get the available cache dir + */ + public static void init(final Context context) { + File externalCacheDir = context.getExternalCacheDir(); + if (externalCacheDir != null) { + cacheDirPath = externalCacheDir.getAbsolutePath(); + } + if (TextUtils.isEmpty(cacheDirPath)) { + cacheDirPath = context.getCacheDir().getAbsolutePath(); + } + } + + /** + * @see #tryToRestore(SavedState, WriteRead) + * @param outState + * @param writeRead + * @return the saved state + */ + public static SavedState tryToRestore(final Bundle outState, final WriteRead writeRead) { + if (outState == null || writeRead == null) { + return null; + } + + SavedState savedState = outState.getParcelable(KEY_SAVED_STATE); + if (savedState == null) { + return null; + } + + return tryToRestore(savedState, writeRead); + } + + /** + * Try to restore the state from memory and disk, + * using the {@link StateSaver.WriteRead#readFrom(Queue)} from the writeRead. + * @param savedState + * @param writeRead + * @return the saved state + */ + @Nullable + private static SavedState tryToRestore(@NonNull final SavedState savedState, + @NonNull final WriteRead writeRead) { + if (MainActivity.DEBUG) { + Log.d(TAG, "tryToRestore() called with: savedState = [" + savedState + "], " + + "writeRead = [" + writeRead + "]"); + } + + FileInputStream fileInputStream = null; + try { + Queue savedObjects + = STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); + if (savedObjects != null) { + writeRead.readFrom(savedObjects); + if (MainActivity.DEBUG) { + Log.d(TAG, "tryToSave: reading objects from holder > " + savedObjects + + ", stateObjectsHolder > " + STATE_OBJECTS_HOLDER); + } + return savedState; + } + + File file = new File(savedState.getPathFileSaved()); + if (!file.exists()) { + if (MainActivity.DEBUG) { + Log.d(TAG, "Cache file doesn't exist: " + file.getAbsolutePath()); + } + return null; + } + + fileInputStream = new FileInputStream(file); + ObjectInputStream inputStream = new ObjectInputStream(fileInputStream); + //noinspection unchecked + savedObjects = (Queue) inputStream.readObject(); + if (savedObjects != null) { + writeRead.readFrom(savedObjects); + } + + return savedState; + } catch (Exception e) { + Log.e(TAG, "Failed to restore state", e); + } finally { + if (fileInputStream != null) { + try { + fileInputStream.close(); + } catch (IOException ignored) { + } + } + } + return null; + } + + /** + * @see #tryToSave(boolean, String, String, WriteRead) + * @param isChangingConfig + * @param savedState + * @param outState + * @param writeRead + * @return the saved state or {@code null} + */ + @Nullable + public static SavedState tryToSave(final boolean isChangingConfig, + @Nullable final SavedState savedState, final Bundle outState, + final WriteRead writeRead) { + @NonNull + String currentSavedPrefix; + if (savedState == null || TextUtils.isEmpty(savedState.getPrefixFileSaved())) { + // Generate unique prefix + currentSavedPrefix = System.nanoTime() - writeRead.hashCode() + ""; + } else { + // Reuse prefix + currentSavedPrefix = savedState.getPrefixFileSaved(); + } + + final SavedState newSavedState = tryToSave(isChangingConfig, currentSavedPrefix, + writeRead.generateSuffix(), writeRead); + if (newSavedState != null) { + outState.putParcelable(StateSaver.KEY_SAVED_STATE, newSavedState); + return newSavedState; + } + + return null; + } + + /** + * If it's not changing configuration (i.e. rotating screen), + * try to write the state from {@link StateSaver.WriteRead#writeTo(Queue)} + * to the file with the name of prefixFileName + suffixFileName, + * in a cache folder got from the {@link #init(Context)}. + *

+ * It checks if the file already exists and if it does, just return the path, + * so a good way to save is: + *

+ *
    + *
  • A fixed prefix for the file
  • + *
  • A changing suffix
  • + *
+ * + * @param isChangingConfig + * @param prefixFileName + * @param suffixFileName + * @param writeRead + * @return the saved state or {@code null} + */ + @Nullable + private static SavedState tryToSave(final boolean isChangingConfig, final String prefixFileName, + final String suffixFileName, final WriteRead writeRead) { + if (MainActivity.DEBUG) { + Log.d(TAG, "tryToSave() called with: " + + "isChangingConfig = [" + isChangingConfig + "], " + + "prefixFileName = [" + prefixFileName + "], " + + "suffixFileName = [" + suffixFileName + "], " + + "writeRead = [" + writeRead + "]"); + } + + LinkedList savedObjects = new LinkedList<>(); + writeRead.writeTo(savedObjects); + + if (isChangingConfig) { + if (savedObjects.size() > 0) { + STATE_OBJECTS_HOLDER.put(prefixFileName, savedObjects); + return new SavedState(prefixFileName, ""); + } else { + if (MainActivity.DEBUG) { + Log.d(TAG, "Nothing to save"); + } + return null; + } + } + + FileOutputStream fileOutputStream = null; + try { + File cacheDir = new File(cacheDirPath); + if (!cacheDir.exists()) { + throw new RuntimeException("Cache dir does not exist > " + cacheDirPath); + } + cacheDir = new File(cacheDir, CACHE_DIR_NAME); + if (!cacheDir.exists()) { + if (!cacheDir.mkdir()) { + if (BuildConfig.DEBUG) { + Log.e(TAG, + "Failed to create cache directory " + cacheDir.getAbsolutePath()); + } + return null; + } + } + + File file = new File(cacheDir, prefixFileName + + (TextUtils.isEmpty(suffixFileName) ? ".cache" : suffixFileName)); + if (file.exists() && file.length() > 0) { + // If the file already exists, just return it + return new SavedState(prefixFileName, file.getAbsolutePath()); + } else { + // Delete any file that contains the prefix + File[] files = cacheDir.listFiles(new FilenameFilter() { + @Override + public boolean accept(final File dir, final String name) { + return name.contains(prefixFileName); + } + }); + for (File fileToDelete : files) { + fileToDelete.delete(); + } + } + + fileOutputStream = new FileOutputStream(file); + ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream); + outputStream.writeObject(savedObjects); + + return new SavedState(prefixFileName, file.getAbsolutePath()); + } catch (Exception e) { + Log.e(TAG, "Failed to save state", e); + } finally { + if (fileOutputStream != null) { + try { + fileOutputStream.close(); + } catch (IOException ignored) { } + } + } + return null; + } + + /** + * Delete the cache file contained in the savedState. + * Also remove any possible-existing value in the memory-cache. + * + * @param savedState the saved state to delete + */ + public static void onDestroy(final SavedState savedState) { + if (MainActivity.DEBUG) { + Log.d(TAG, "onDestroy() called with: savedState = [" + savedState + "]"); + } + + if (savedState != null && !TextUtils.isEmpty(savedState.getPathFileSaved())) { + STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); + try { + //noinspection ResultOfMethodCallIgnored + new File(savedState.getPathFileSaved()).delete(); + } catch (Exception ignored) { + } + } + } + + /** + * Clear all the files in cache (in memory and disk). + */ + public static void clearStateFiles() { + if (MainActivity.DEBUG) { + Log.d(TAG, "clearStateFiles() called"); + } + + STATE_OBJECTS_HOLDER.clear(); + File cacheDir = new File(cacheDirPath); + if (!cacheDir.exists()) { + return; + } + + cacheDir = new File(cacheDir, CACHE_DIR_NAME); + if (cacheDir.exists()) { + for (File file : cacheDir.listFiles()) { + file.delete(); + } + } + } + + /** + * Used for describe how to save/read the objects. + *

+ * Queue was chosen by its FIFO property. + */ + public interface WriteRead { + /** + * Generate a changing suffix that will name the cache file, + * and be used to identify if it changed (thus reducing useless reading/saving). + * + * @return a unique value + */ + String generateSuffix(); + + /** + * Add to this queue objects that you want to save. + * + * @param objectsToSave the objects to save + */ + void writeTo(Queue objectsToSave); + + /** + * Poll saved objects from the queue in the order they were written. + * + * @param savedObjects queue of objects returned by {@link #writeTo(Queue)} + */ + void readFrom(@NonNull Queue savedObjects) throws Exception; + } + + /*////////////////////////////////////////////////////////////////////////// + // Inner + //////////////////////////////////////////////////////////////////////////*/ + + /** + * Information about the saved state on the disk. + */ + public static class SavedState implements Parcelable { + @SuppressWarnings("unused") + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + private final String prefixFileSaved; + private final String pathFileSaved; + + public SavedState(final String prefixFileSaved, final String pathFileSaved) { + this.prefixFileSaved = prefixFileSaved; + this.pathFileSaved = pathFileSaved; + } + + protected SavedState(final Parcel in) { + prefixFileSaved = in.readString(); + pathFileSaved = in.readString(); + } + + @Override + public String toString() { + return getPrefixFileSaved() + " > " + getPathFileSaved(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(prefixFileSaved); + dest.writeString(pathFileSaved); + } + + /** + * Get the prefix of the saved file. + * + * @return the file prefix + */ + public String getPrefixFileSaved() { + return prefixFileSaved; + } + + /** + * Get the path to the saved file. + * + * @return the path to the saved file + */ + public String getPathFileSaved() { + return pathFileSaved; + } + } + + +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipelegacy/util/StreamDialogEntry.java new file mode 100644 index 000000000..f299e642b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/StreamDialogEntry.java @@ -0,0 +1,121 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; + +import androidx.fragment.app.Fragment; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipelegacy.local.dialog.PlaylistAppendDialog; +import org.schabi.newpipelegacy.player.playqueue.SinglePlayQueue; + +import java.util.Collections; + +public enum StreamDialogEntry { + ////////////////////////////////////// + // enum values with DEFAULT actions // + ////////////////////////////////////// + + enqueue_on_background(R.string.enqueue_on_background, (fragment, item) -> + NavigationHelper.enqueueOnBackgroundPlayer(fragment.getContext(), + new SinglePlayQueue(item), false)), + + enqueue_on_popup(R.string.enqueue_on_popup, (fragment, item) -> + NavigationHelper.enqueueOnPopupPlayer(fragment.getContext(), + new SinglePlayQueue(item), false)), + + start_here_on_background(R.string.start_here_on_background, (fragment, item) -> + NavigationHelper.playOnBackgroundPlayer(fragment.getContext(), + new SinglePlayQueue(item), true)), + + start_here_on_popup(R.string.start_here_on_popup, (fragment, item) -> + NavigationHelper.playOnPopupPlayer(fragment.getContext(), + new SinglePlayQueue(item), true)), + + set_as_playlist_thumbnail(R.string.set_as_playlist_thumbnail, (fragment, item) -> { + }), // has to be set manually + + delete(R.string.delete, (fragment, item) -> { + }), // has to be set manually + + append_playlist(R.string.append_playlist, (fragment, item) -> { + if (fragment.getFragmentManager() != null) { + PlaylistAppendDialog.fromStreamInfoItems(Collections.singletonList(item)) + .show(fragment.getFragmentManager(), "StreamDialogEntry@append_playlist"); + } + }), + + share(R.string.share, (fragment, item) -> + ShareUtils.shareUrl(fragment.getContext(), item.getName(), item.getUrl())); + + + /////////////// + // variables // + /////////////// + + private static StreamDialogEntry[] enabledEntries; + private final int resource; + private final StreamDialogEntryAction defaultAction; + private StreamDialogEntryAction customAction; + + StreamDialogEntry(final int resource, final StreamDialogEntryAction defaultAction) { + this.resource = resource; + this.defaultAction = defaultAction; + this.customAction = null; + } + + + /////////////////////////////////////////////////////// + // non-static methods to initialize and edit entries // + /////////////////////////////////////////////////////// + + /** + * To be called before using {@link #setCustomAction(StreamDialogEntryAction)}. + * + * @param entries the entries to be enabled + */ + public static void setEnabledEntries(final StreamDialogEntry... entries) { + // cleanup from last time StreamDialogEntry was used + for (StreamDialogEntry streamDialogEntry : values()) { + streamDialogEntry.customAction = null; + } + + enabledEntries = entries; + } + + public static String[] getCommands(final Context context) { + String[] commands = new String[enabledEntries.length]; + for (int i = 0; i != enabledEntries.length; ++i) { + commands[i] = context.getResources().getString(enabledEntries[i].resource); + } + + return commands; + } + + + //////////////////////////////////////////////// + // static methods that act on enabled entries // + //////////////////////////////////////////////// + + public static void clickOn(final int which, final Fragment fragment, + final StreamInfoItem infoItem) { + if (enabledEntries[which].customAction == null) { + enabledEntries[which].defaultAction.onClick(fragment, infoItem); + } else { + enabledEntries[which].customAction.onClick(fragment, infoItem); + } + } + + /** + * Can be used after {@link #setEnabledEntries(StreamDialogEntry...)} has been called. + * + * @param action the action to be set + */ + public void setCustomAction(final StreamDialogEntryAction action) { + this.customAction = action; + } + + public interface StreamDialogEntryAction { + void onClick(Fragment fragment, StreamInfoItem infoItem); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipelegacy/util/StreamItemAdapter.java new file mode 100644 index 000000000..2b4b25016 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/StreamItemAdapter.java @@ -0,0 +1,266 @@ +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.Spinner; +import android.widget.TextView; + +import org.schabi.newpipelegacy.DownloaderImpl; +import org.schabi.newpipelegacy.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; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Callable; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.schedulers.Schedulers; +import us.shandian.giga.util.Utility; + +/** + * A list adapter for a list of {@link Stream streams}. + * It currently supports {@link VideoStream}, {@link AudioStream} and {@link SubtitlesStream}. + * + * @param the primary stream type's class extending {@link Stream} + * @param the secondary stream type's class extending {@link Stream} + */ +public class StreamItemAdapter extends BaseAdapter { + private final Context context; + + private final StreamSizeWrapper streamsWrapper; + private final SparseArray> secondaryStreams; + + public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper, + final SparseArray> secondaryStreams) { + this.context = context; + this.streamsWrapper = streamsWrapper; + this.secondaryStreams = secondaryStreams; + } + + public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper, + final boolean showIconNoAudio) { + this(context, streamsWrapper, showIconNoAudio ? new SparseArray<>() : null); + } + + public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper) { + this(context, streamsWrapper, null); + } + + public List getAll() { + return streamsWrapper.getStreamsList(); + } + + public SparseArray> getAllSecondary() { + return secondaryStreams; + } + + @Override + public int getCount() { + return streamsWrapper.getStreamsList().size(); + } + + @Override + public T getItem(final int position) { + return streamsWrapper.getStreamsList().get(position); + } + + @Override + public long getItemId(final int position) { + return position; + } + + @Override + public View getDropDownView(final int position, final View convertView, + final ViewGroup parent) { + return getCustomView(position, convertView, parent, true); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + return getCustomView(((Spinner) parent).getSelectedItemPosition(), + convertView, parent, false); + } + + private View getCustomView(final int position, final View view, final ViewGroup parent, + final boolean isDropdownItem) { + View convertView = view; + if (convertView == null) { + convertView = LayoutInflater.from(context).inflate( + R.layout.stream_quality_item, parent, false); + } + + final ImageView woSoundIconView = convertView.findViewById(R.id.wo_sound_icon); + final TextView formatNameView = convertView.findViewById(R.id.stream_format_name); + final TextView qualityView = convertView.findViewById(R.id.stream_quality); + final TextView sizeView = convertView.findViewById(R.id.stream_size); + + final T stream = getItem(position); + + int woSoundIconVisibility = View.GONE; + String qualityString; + + if (stream instanceof VideoStream) { + VideoStream videoStream = ((VideoStream) stream); + qualityString = videoStream.getResolution(); + + 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) { + AudioStream audioStream = ((AudioStream) stream); + qualityString = audioStream.getAverageBitrate() > 0 + ? audioStream.getAverageBitrate() + "kbps" + : audioStream.getFormat().getName(); + } 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) { + 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); + } + + if (stream instanceof SubtitlesStream) { + formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); + } else { + switch (stream.getFormat()) { + case WEBMA_OPUS: + // noinspection AndroidLintSetTextI18n + formatNameView.setText("opus"); + break; + default: + formatNameView.setText(stream.getFormat().getName()); + break; + } + } + + qualityView.setText(qualityString); + woSoundIconView.setVisibility(woSoundIconVisibility); + + return convertView; + } + + /** + * A wrapper class that includes a way of storing the stream sizes. + * + * @param the stream type's class extending {@link Stream} + */ + public static class StreamSizeWrapper implements Serializable { + private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>( + Collections.emptyList(), null); + private final List streamsList; + private final long[] streamSizes; + private final String unknownSize; + + public StreamSizeWrapper(final List sL, final Context context) { + this.streamsList = sL != null + ? sL + : Collections.emptyList(); + this.streamSizes = new long[streamsList.size()]; + this.unknownSize = context == null + ? "--.-" : context.getString(R.string.unknown_content); + + Arrays.fill(streamSizes, -2); + } + + /** + * Helper method to fetch the sizes of all the streams in a wrapper. + * + * @param the stream type's class extending {@link Stream} + * @param streamsWrapper the wrapper + * @return a {@link Single} that returns a boolean indicating if any elements were changed + */ + public static Single fetchSizeForWrapper( + final StreamSizeWrapper streamsWrapper) { + final Callable fetchAndSet = () -> { + boolean hasChanged = false; + for (X stream : streamsWrapper.getStreamsList()) { + if (streamsWrapper.getSizeInBytes(stream) > -2) { + continue; + } + + final long contentLength = DownloaderImpl.getInstance().getContentLength( + stream.getUrl()); + streamsWrapper.setSize(stream, contentLength); + hasChanged = true; + } + return hasChanged; + }; + + return Single.fromCallable(fetchAndSet) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .onErrorReturnItem(true); + } + + public static StreamSizeWrapper empty() { + //noinspection unchecked + return (StreamSizeWrapper) EMPTY; + } + + public List getStreamsList() { + return streamsList; + } + + public long getSizeInBytes(final int streamIndex) { + return streamSizes[streamIndex]; + } + + public long getSizeInBytes(final T stream) { + return streamSizes[streamsList.indexOf(stream)]; + } + + public String getFormattedSize(final int streamIndex) { + return formatSize(getSizeInBytes(streamIndex)); + } + + public String getFormattedSize(final T stream) { + return formatSize(getSizeInBytes(stream)); + } + + private String formatSize(final long size) { + if (size > -1) { + return Utility.formatBytes(size); + } + return unknownSize; + } + + public void setSize(final int streamIndex, final long sizeInBytes) { + streamSizes[streamIndex] = sizeInBytes; + } + + public void setSize(final T stream, final long sizeInBytes) { + streamSizes[streamsList.indexOf(stream)] = sizeInBytes; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/TLSSocketFactoryCompat.java b/app/src/main/java/org/schabi/newpipelegacy/util/TLSSocketFactoryCompat.java new file mode 100644 index 000000000..011166886 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/TLSSocketFactoryCompat.java @@ -0,0 +1,114 @@ +package org.schabi.newpipelegacy.util; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.Socket; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; + +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + + +/** + * This is an extension of the SSLSocketFactory which enables TLS 1.2 and 1.1. + * Created for usage on Android 4.1-4.4 devices, which haven't enabled those by default. + */ +public class TLSSocketFactoryCompat extends SSLSocketFactory { + + + private static TLSSocketFactoryCompat instance = null; + + private SSLSocketFactory internalSSLSocketFactory; + + public TLSSocketFactoryCompat() throws KeyManagementException, NoSuchAlgorithmException { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, null, null); + internalSSLSocketFactory = context.getSocketFactory(); + } + + + public TLSSocketFactoryCompat(final TrustManager[] tm) + throws KeyManagementException, NoSuchAlgorithmException { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, tm, new java.security.SecureRandom()); + internalSSLSocketFactory = context.getSocketFactory(); + } + + public static TLSSocketFactoryCompat getInstance() + throws NoSuchAlgorithmException, KeyManagementException { + if (instance != null) { + return instance; + } + instance = new TLSSocketFactoryCompat(); + return instance; + } + + public static void setAsDefault() { + try { + HttpsURLConnection.setDefaultSSLSocketFactory(getInstance()); + } catch (NoSuchAlgorithmException | KeyManagementException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + + @Override + public String[] getDefaultCipherSuites() { + return internalSSLSocketFactory.getDefaultCipherSuites(); + } + + @Override + public String[] getSupportedCipherSuites() { + return internalSSLSocketFactory.getSupportedCipherSuites(); + } + + @Override + public Socket createSocket() throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket()); + } + + @Override + public Socket createSocket(final Socket s, final String host, final int port, + final boolean autoClose) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)); + } + + @Override + public Socket createSocket(final String host, final int port) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); + } + + @Override + public Socket createSocket(final String host, final int port, final InetAddress localHost, + final int localPort) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket( + host, port, localHost, localPort)); + } + + @Override + public Socket createSocket(final InetAddress host, final int port) throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); + } + + @Override + public Socket createSocket(final InetAddress address, final int port, + final InetAddress localAddress, final int localPort) + throws IOException { + return enableTLSOnSocket(internalSSLSocketFactory.createSocket( + address, port, localAddress, localPort)); + } + + private Socket enableTLSOnSocket(final Socket socket) { + if (socket != null && (socket instanceof SSLSocket)) { + ((SSLSocket) socket).setEnabledProtocols(new String[]{"TLSv1.1", "TLSv1.2"}); + } + return socket; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/ThemeHelper.java new file mode 100644 index 000000000..49dd3c993 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ThemeHelper.java @@ -0,0 +1,234 @@ +/* + * Copyright 2018 Mauricio Colli + * ThemeHelper.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.util; + +import android.content.Context; +import android.content.res.TypedArray; +import android.preference.PreferenceManager; +import android.util.TypedValue; +import android.view.ContextThemeWrapper; + +import androidx.annotation.AttrRes; +import androidx.annotation.StyleRes; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; + +public final class ThemeHelper { + private ThemeHelper() { } + + /** + * Apply the selected theme (on NewPipe settings) in the context + * with the default style (see {@link #setTheme(Context, int)}). + * + * @param context context that the theme will be applied + */ + public static void setTheme(final Context context) { + setTheme(context, -1); + } + + /** + * Apply the selected theme (on NewPipe settings) in the context, + * themed according with the styles defined for the service . + * + * @param context context that the theme will be applied + * @param serviceId the theme will be styled to the service with this id, + * pass -1 to get the default style + */ + public static void setTheme(final Context context, final int serviceId) { + context.setTheme(getThemeForService(context, serviceId)); + } + + /** + * Return true if the selected theme (on NewPipe settings) is the Light theme. + * + * @param context context to get the preference + * @return whether the light theme is selected + */ + public static boolean isLightThemeSelected(final Context context) { + return getSelectedThemeString(context).equals(context.getResources() + .getString(R.string.light_theme_key)); + } + + + /** + * Create and return a wrapped context with the default selected theme set. + * + * @param baseContext the base context for the wrapper + * @return a wrapped-styled context + */ + public static Context getThemedContext(final Context baseContext) { + return new ContextThemeWrapper(baseContext, getThemeForService(baseContext, -1)); + } + + /** + * Return the selected theme without being styled to any service. + * See {@link #getThemeForService(Context, int)}. + * + * @param context context to get the selected theme + * @return the selected style (the default one) + */ + @StyleRes + public static int getDefaultTheme(final Context context) { + return getThemeForService(context, -1); + } + + /** + * Return a dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getDialogTheme(final Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; + } + + /** + * Return a min-width dialog theme styled according to the (default) selected theme. + * + * @param context context to get the selected theme + * @return the dialog style (the default one) + */ + @StyleRes + public static int getMinWidthDialogTheme(final Context context) { + return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme + : R.style.DarkDialogMinWidthTheme; + } + + /** + * Return the selected theme styled according to the serviceId. + * + * @param context context to get the selected theme + * @param serviceId return a theme styled to this service, + * -1 to get the default + * @return the selected style (styled) + */ + @StyleRes + public static int getThemeForService(final Context context, final int serviceId) { + String lightTheme = context.getResources().getString(R.string.light_theme_key); + String darkTheme = context.getResources().getString(R.string.dark_theme_key); + String blackTheme = context.getResources().getString(R.string.black_theme_key); + + String selectedTheme = getSelectedThemeString(context); + + int defaultTheme = R.style.DarkTheme; + if (selectedTheme.equals(lightTheme)) { + defaultTheme = R.style.LightTheme; + } else if (selectedTheme.equals(blackTheme)) { + defaultTheme = R.style.BlackTheme; + } else if (selectedTheme.equals(darkTheme)) { + defaultTheme = R.style.DarkTheme; + } + + if (serviceId <= -1) { + return defaultTheme; + } + + final StreamingService service; + try { + service = NewPipe.getService(serviceId); + } catch (ExtractionException ignored) { + return defaultTheme; + } + + String themeName = "DarkTheme"; + if (selectedTheme.equals(lightTheme)) { + themeName = "LightTheme"; + } else if (selectedTheme.equals(blackTheme)) { + themeName = "BlackTheme"; + } else if (selectedTheme.equals(darkTheme)) { + themeName = "DarkTheme"; + } + + themeName += "." + service.getServiceInfo().getName(); + int resourceId = context + .getResources() + .getIdentifier(themeName, "style", context.getPackageName()); + + if (resourceId > 0) { + return resourceId; + } + + return defaultTheme; + } + + @StyleRes + public static int getSettingsThemeStyle(final Context context) { + String lightTheme = context.getResources().getString(R.string.light_theme_key); + String darkTheme = context.getResources().getString(R.string.dark_theme_key); + String blackTheme = context.getResources().getString(R.string.black_theme_key); + + String selectedTheme = getSelectedThemeString(context); + + if (selectedTheme.equals(lightTheme)) { + return R.style.LightSettingsTheme; + } else if (selectedTheme.equals(blackTheme)) { + return R.style.BlackSettingsTheme; + } else if (selectedTheme.equals(darkTheme)) { + return R.style.DarkSettingsTheme; + } else { + // Fallback + return R.style.DarkSettingsTheme; + } + } + + /** + * Get a resource id from a resource styled according to the context's theme. + * + * @param context Android app context + * @param attr attribute reference of the resource + * @return resource ID + */ + public static int resolveResourceIdFromAttr(final Context context, @AttrRes final int attr) { + TypedArray a = context.getTheme().obtainStyledAttributes(new int[]{attr}); + int attributeResourceId = a.getResourceId(0, 0); + a.recycle(); + return attributeResourceId; + } + + /** + * Get a color from an attr styled according to the context's theme. + * + * @param context Android app context + * @param attrColor attribute reference of the resource + * @return the color + */ + public static int resolveColorFromAttr(final Context context, @AttrRes final int attrColor) { + final TypedValue value = new TypedValue(); + context.getTheme().resolveAttribute(attrColor, value, true); + + if (value.resourceId != 0) { + return ContextCompat.getColor(context, value.resourceId); + } + + return value.data; + } + + private static String getSelectedThemeString(final Context context) { + String themeKey = context.getString(R.string.theme_key); + String defaultTheme = context.getResources().getString(R.string.default_theme_value); + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(themeKey, defaultTheme); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipelegacy/util/ZipHelper.java new file mode 100644 index 000000000..093d28b65 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/ZipHelper.java @@ -0,0 +1,104 @@ +package org.schabi.newpipelegacy.util; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +/** + * Created by Christian Schabesberger on 28.01.18. + * Copyright 2018 Christian Schabesberger + * ZipHelper.java is part of NewPipe + *

+ * License: GPL-3.0+ + * This program 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. + *

+ * This program 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 this program. If not, see . + */ + +public final class ZipHelper { + private ZipHelper() { } + + private static final int BUFFER_SIZE = 2048; + + /** + * This function helps to create zip files. + * Caution this will override the original file. + * + * @param outZip The ZipOutputStream where the data should be stored in + * @param file The path of the file that should be added to zip. + * @param name The path of the file inside the zip. + * @throws Exception + */ + public static void addFileToZip(final ZipOutputStream outZip, final String file, + final String name) throws Exception { + byte[] data = new byte[BUFFER_SIZE]; + FileInputStream fi = new FileInputStream(file); + BufferedInputStream inputStream = new BufferedInputStream(fi, BUFFER_SIZE); + ZipEntry entry = new ZipEntry(name); + outZip.putNextEntry(entry); + int count; + while ((count = inputStream.read(data, 0, BUFFER_SIZE)) != -1) { + outZip.write(data, 0, count); + } + inputStream.close(); + } + + /** + * This will extract data from Zipfiles. + * Caution this will override the original file. + * + * @param filePath The path of the zip + * @param file The path of the file on the disk where the data should be extracted to. + * @param name The path of the file inside the zip. + * @return will return true if the file was found within the zip file + * @throws Exception + */ + public static boolean extractFileFromZip(final String filePath, final String file, + final String name) throws Exception { + + ZipInputStream inZip = new ZipInputStream( + new BufferedInputStream( + new FileInputStream(filePath))); + + byte[] data = new byte[BUFFER_SIZE]; + + boolean found = false; + + ZipEntry ze; + while ((ze = inZip.getNextEntry()) != null) { + if (ze.getName().equals(name)) { + found = true; + // delete old file first + File oldFile = new File(file); + if (oldFile.exists()) { + if (!oldFile.delete()) { + throw new Exception("Could not delete " + file); + } + } + + FileOutputStream outFile = new FileOutputStream(file); + int count = 0; + while ((count = inZip.read(data)) != -1) { + outFile.write(data, 0, count); + } + + outFile.close(); + inZip.closeEntry(); + } + } + return found; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipelegacy/util/urlfinder/PatternsCompat.java new file mode 100644 index 000000000..c5dc0a990 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/util/urlfinder/PatternsCompat.java @@ -0,0 +1,377 @@ +/* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */ + +/* + * Copyright (C) 2016 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.schabi.newpipelegacy.util.urlfinder; + +import androidx.annotation.RestrictTo; + +import java.util.regex.Pattern; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; + +/** + * Commonly used regular expression patterns. + */ +public final class PatternsCompat { + /** + * Regular expression to match all IANA top-level domains. + * + * List accurate as of 2015/11/24. List taken from: + * http://data.iana.org/TLD/tlds-alpha-by-domain.txt + * This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py + */ + static final String IANA_TOP_LEVEL_DOMAINS = "(?:" + + "(?:aaa|aarp|abb|abbott|abogado|academy|accenture|accountant|accountants|aco|active" + + "|actor|ads|adult|aeg|aero|afl|agency|aig|airforce|airtel|allfinanz|alsace|amica" + + "|amsterdam|android|apartments|app|apple|aquarelle|aramco|archi|army|arpa|arte|asia" + + "|associates|attorney|auction|audio|auto|autos|axa|azure|a[cdefgilmoqrstuwxz])" + + "|(?:band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bbc|bbva" + + "|bcn|beats|beer|bentley|berlin|best|bet|bharti|bible|bid|bike|bing|bingo|bio|biz" + + "|black|blackfriday|bloomberg|blue|bms|bmw|bnl|bnpparibas|boats|bom|bond|boo|boots" + + "|boutique|bradesco|bridgestone|broadway|broker|brother|brussels|budapest|build" + + "|builders|business|buzz|bzh|b[abdefghijmnorstvwyz])" + + "|(?:cab|cafe|cal|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards" + + "|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|ceb|center" + + "|ceo|cern|cfa|cfd|chanel|channel|chat|cheap|chloe|christmas|chrome|church|cipriani" + + "|cisco|citic|city|cityeats|claims|cleaning|click|clinic|clothing|cloud|club|clubmed" + + "|coach|codes|coffee|college|cologne|com|commbank|community|company|computer|comsec" + + "|condos|construction|consulting|contractors|cooking|cool|coop|corsica|country" + + "|coupons|courses|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc" + + "|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])" + + "|(?:dabur|dad|dance|date|dating|datsun|day|dclk|deals|degree|delivery|dell|delta" + + "|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory" + + "|discount|dnp|docs|dog|doha|domains|doosan|download|drive|durban|dvag|d[ejkmoz])" + + "|(?:earth|eat|edu|education|email|emerck|energy|engineer|engineering|enterprises" + + "|epson|equipment|erni|esq|estate|eurovision|eus|events|everbank|exchange|expert" + + "|exposed|express|e[cegrstu])" + + "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm" + + "|fashion|feedback|ferrero|film|final|finance|financial|firmdale|fish|fishing|fit" + + "|fitness|flights|florist|flowers|flsmidth|fly|foo|football|forex|forsale|forum" + + "|foundation|frl|frogans|fund|furniture|futbol|fyi|f[ijkmor])" + + "|(?:gal|gallery|game|garden|gbiz|gdn|gea|gent|genting|ggee|gift|gifts|gives|giving" + + "|glass|gle|global|globo|gmail|gmo|gmx|gold|goldpoint|golf|goo|goog|google|gop|gov" + + "|grainger|graphics|gratis|green|gripe|group|gucci|guge|guide|guitars|guru" + + "|g[abdefghilmnpqrstuwy])" + + "|(?:hamburg|hangout|haus|healthcare|help|here|hermes|hiphop|hitachi|hiv|hockey" + + "|holdings|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house" + + "|how|hsbc|hyundai|h[kmnrtu])" + + "|(?:ibm|icbc|ice|icu|ifm|iinet|immo|immobilien|industries|infiniti|info|ing|ink" + + "|institute|insure|int|international|investments|ipiranga|irish|ist|istanbul|itau" + + "|iwc|i[delmnoqrst])" + + "|(?:jaguar|java|jcb|jetzt|jewelry|jlc|jll|jobs|joburg|jprs|juegos|j[emop])" + + "|(?:kaufen|kddi|kia|kim|kinder|kitchen|kiwi|koeln|komatsu|krd|kred|kyoto" + + "|k[eghimnprwyz])" + + "|(?:lacaixa|lancaster|land|landrover|lasalle|lat|latrobe|law|lawyer|lds|lease" + + "|leclerc|legal|lexus|lgbt|liaison|lidl|life|lifestyle|lighting|limited|limo|linde" + + "|link|live|lixil|loan|loans|lol|london|lotte|lotto|love|ltd|ltda|lupin|luxe|luxury" + + "|l[abcikrstuvy])" + + "|(?:madrid|maif|maison|man|management|mango|market|marketing|markets|marriott|mba" + + "|media|meet|melbourne|meme|memorial|men|menu|meo|miami|microsoft|mil|mini|mma|mobi" + + "|moda|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov" + + "|movie|movistar|mtn|mtpc|mtr|museum|mutuelle|m[acdeghklmnopqrstuvwxyz])" + + "|(?:nadex|nagoya|name|navy|nec|net|netbank|network|neustar|new|news|nexus|ngo|nhk" + + "|nico|ninja|nissan|nokia|nra|nrw|ntt|nyc|n[acefgilopruz])" + + "|(?:obi|office|okinawa|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|osaka" + + "|otsuka|ovh|om)" + + "|(?:page|panerai|paris|partners|parts|party|pet|pharmacy|philips|photo|photography" + + "|photos|physio|piaget|pics|pictet|pictures|ping|pink|pizza|place|play|playstation" + + "|plumbing|plus|pohl|poker|porn|post|praxi|press|pro|prod|productions|prof|properties" + + "|property|protection|pub|p[aefghklmnrstwy])" + + "|(?:qpon|quebec|qa)" + + "|(?:racing|realtor|realty|recipes|red|redstone|rehab|reise|reisen|reit|ren|rent" + + "|rentals|repair|report|republican|rest|restaurant|review|reviews|rich|ricoh|rio|rip" + + "|rocher|rocks|rodeo|rsvp|ruhr|run|rwe|ryukyu|r[eosuw])" + + "|(?:saarland|sakura|sale|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|saxo" + + "|sbs|sca|scb|schmidt|scholarships|school|schule|schwarz|science|scor|scot|seat" + + "|security|seek|sener|services|seven|sew|sex|sexy|shiksha|shoes|show|shriram|singles" + + "|site|ski|sky|skype|sncf|soccer|social|software|sohu|solar|solutions|sony|soy|space" + + "|spiegel|spreadbetting|srl|stada|starhub|statoil|stc|stcgroup|stockholm|studio|study" + + "|style|sucks|supplies|supply|support|surf|surgery|suzuki|swatch|swiss|sydney|systems" + + "|s[abcdeghijklmnortuvxyz])" + + "|(?:tab|taipei|tatamotors|tatar|tattoo|tax|taxi|team|tech|technology|tel|telefonica" + + "|temasek|tennis|thd|theater|theatre|tickets|tienda|tips|tires|tirol|today|tokyo" + + "|tools|top|toray|toshiba|tours|town|toyota|toys|trade|trading|training|travel|trust" + + "|tui|t[cdfghjklmnortvwz])" + + "|(?:ubs|university|uno|uol|u[agksyz])" + + "|(?:vacations|vana|vegas|ventures|versicherung|vet|viajes|video|villas|vin|virgin" + + "|vision|vista|vistaprint|viva|vlaanderen|vodka|vote|voting|voto|voyage|v[aceginu])" + + "|(?:wales|walter|wang|watch|webcam|website|wed|wedding|weir|whoswho|wien|wiki" + + "|williamhill|win|windows|wine|wme|work|works|world|wtc|wtf|w[fs])" + + "|(?:\u03b5\u03bb|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438|\u043a\u043e\u043c" + + "|\u043c\u043a\u0434|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430" + + "|\u043e\u043d\u043b\u0430\u0439\u043d|\u043e\u0440\u0433|\u0440\u0443\u0441" + + "|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431|\u0443\u043a\u0440" + + "|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05e7\u05d5\u05dd" + + "|\u0627\u0631\u0627\u0645\u0643\u0648|\u0627\u0644\u0627\u0631\u062f\u0646" + + "|\u0627\u0644\u062c\u0632\u0627\u0626\u0631" + + "|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629" + + "|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a" + + "|\u0627\u06cc\u0631\u0627\u0646|\u0628\u0627\u0632\u0627\u0631" + + "|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633" + + "|\u0633\u0648\u062f\u0627\u0646|\u0633\u0648\u0631\u064a\u0629" + + "|\u0634\u0628\u0643\u0629|\u0639\u0631\u0627\u0642|\u0639\u0645\u0627\u0646" + + "|\u0641\u0644\u0633\u0637\u064a\u0646|\u0642\u0637\u0631|\u0643\u0648\u0645" + + "|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627|\u0645\u0648\u0642\u0639" + + "|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924" + + "|\u0938\u0902\u0917\u0920\u0928|\u09ad\u09be\u09b0\u09a4|\u0a2d\u0a3e\u0a30\u0a24" + + "|\u0aad\u0abe\u0ab0\u0aa4|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe" + + "|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8" + + "|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd" + + "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21" + + "|\u0e44\u0e17\u0e22|\u10d2\u10d4|\u307f\u3093\u306a|\u30b0\u30fc\u30b0\u30eb" + + "|\u30b3\u30e0|\u4e16\u754c|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51" + + "|\u4f01\u4e1a|\u4f5b\u5c71|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366|\u516c\u53f8" + + "|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063|\u5546\u57ce|\u5546\u5e97|\u5546\u6807" + + "|\u5728\u7ebf|\u5927\u62ff|\u5a31\u4e50|\u5de5\u884c|\u5e7f\u4e1c|\u6148\u5584" + + "|\u6211\u7231\u4f60|\u624b\u673a|\u653f\u52a1|\u653f\u5e9c|\u65b0\u52a0\u5761" + + "|\u65b0\u95fb|\u65f6\u5c1a|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f|\u70b9\u770b" + + "|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7edc" + + "|\u8c37\u6b4c|\u96c6\u56e2|\u98de\u5229\u6d66|\u9910\u5385|\u9999\u6e2f|\ub2f7\ub137" + + "|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d|xbox|xerox|xin|xn\\-\\-11b4c3d" + + "|xn\\-\\-1qqw23a|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g|xn\\-\\-3e0b707e" + + "|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45brj9c|xn\\-\\-45q11c|xn\\-\\-4gbrim" + + "|xn\\-\\-55qw42g|xn\\-\\-55qx5d|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks" + + "|xn\\-\\-80ao21a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-90a3ac|xn\\-\\-90ais" + + "|xn\\-\\-9dbq2a|xn\\-\\-9et52u|xn\\-\\-b4w605ferd|xn\\-\\-c1avg|xn\\-\\-c2br7g" + + "|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd|xn\\-\\-czr694b|xn\\-\\-czrs0t" + + "|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-efvy88h|xn\\-\\-estv75g" + + "|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s" + + "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-gecrj9c" + + "|xn\\-\\-h2brj9c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n|xn\\-\\-io0a7i" + + "|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d" + + "|xn\\-\\-kpry57d|xn\\-\\-kput3i|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf" + + "|xn\\-\\-mgba3a3ejt|xn\\-\\-mgba3a4f16a|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd" + + "|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a71e|xn\\-\\-mgbc0a9azcg|xn\\-\\-mgberp4a5d4ar" + + "|xn\\-\\-mgbpl2fh|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m" + + "|xn\\-\\-ngbc5azd|xn\\-\\-node|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema|xn\\-\\-nyqy26a" + + "|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh" + + "|xn\\-\\-pssy2u|xn\\-\\-q9jyb4c|xn\\-\\-qcka1pmc|xn\\-\\-qxam|xn\\-\\-rhqv96g" + + "|xn\\-\\-s9brj9c|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-unup4y" + + "|xn\\-\\-vermgensberater\\-ctb|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv" + + "|xn\\-\\-vuq861b|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a|xn\\-\\-xhq521b|xn\\-\\-xkc2al3hye2a" + + "|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o|xn\\-\\-ygbi2ammx" + + "|xn\\-\\-zfr164b|xperia|xxx|xyz)" + + "|(?:yachts|yamaxun|yandex|yodobashi|yoga|yokohama|youtube|y[et])" + + "|(?:zara|zip|zone|zuerich|z[amw]))"; + + public static final Pattern IP_ADDRESS + = Pattern.compile( + "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + + "|[1-9][0-9]|[0-9]))"); + + /** + * Valid UCS characters defined in RFC 3987. Excludes space characters. + */ + private static final String UCS_CHAR = "[" + + "\u00A0-\uD7FF" + + "\uF900-\uFDCF" + + "\uFDF0-\uFFEF" + + "\uD800\uDC00-\uD83F\uDFFD" + + "\uD840\uDC00-\uD87F\uDFFD" + + "\uD880\uDC00-\uD8BF\uDFFD" + + "\uD8C0\uDC00-\uD8FF\uDFFD" + + "\uD900\uDC00-\uD93F\uDFFD" + + "\uD940\uDC00-\uD97F\uDFFD" + + "\uD980\uDC00-\uD9BF\uDFFD" + + "\uD9C0\uDC00-\uD9FF\uDFFD" + + "\uDA00\uDC00-\uDA3F\uDFFD" + + "\uDA40\uDC00-\uDA7F\uDFFD" + + "\uDA80\uDC00-\uDABF\uDFFD" + + "\uDAC0\uDC00-\uDAFF\uDFFD" + + "\uDB00\uDC00-\uDB3F\uDFFD" + + "\uDB44\uDC00-\uDB7F\uDFFD" + + "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]"; + + /** + * Valid characters for IRI label defined in RFC 3987. + */ + private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR; + + /** + * Valid characters for IRI TLD defined in RFC 3987. + */ + private static final String TLD_CHAR = "a-zA-Z" + UCS_CHAR; + + /** + * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets. + */ + private static final String IRI_LABEL + = "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; + + /** + * RFC 3492 references RFC 1034 and limits Punycode algorithm output to 63 characters. + */ + private static final String PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w"; + + private static final String TLD = "(" + PUNYCODE_TLD + "|" + "[" + TLD_CHAR + "]{2,63}" + ")"; + + private static final String HOST_NAME = "(" + IRI_LABEL + "\\.)+" + TLD; + + public static final Pattern DOMAIN_NAME + = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")"); + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // CHANGED: Removed rtsp from supported protocols // + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private static final String PROTOCOL = "(?i:http|https)://"; + + /* A word boundary or end of input. This is to stop foo.sure from matching as foo.su */ + private static final String WORD_BOUNDARY = "(?:\\b|$|^)"; + + private static final String USER_INFO = "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@"; + + private static final String PORT_NUMBER = "\\:\\d{1,5}"; + + private static final String PATH_AND_QUERY = "[/\\?](?:(?:[" + LABEL_CHAR + + ";/\\?:@&=#~" // plus optional query params + + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*"; + + /** + * Regular expression pattern to match most part of RFC 3987 + * Internationalized URLs, aka IRIs. + */ + public static final Pattern WEB_URL = Pattern.compile("(" + + "(" + + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")?" + + "(?:" + DOMAIN_NAME + ")" + + "(?:" + PORT_NUMBER + ")?" + + ")" + + "(" + PATH_AND_QUERY + ")?" + + WORD_BOUNDARY + + ")"); + + /** + * Regular expression that matches known TLDs and punycode TLDs. + */ + private static final String STRICT_TLD = "(?:" + + IANA_TOP_LEVEL_DOMAINS + "|" + PUNYCODE_TLD + ")"; + + /** + * Regular expression that matches host names using {@link #STRICT_TLD}. + */ + private static final String STRICT_HOST_NAME = "(?:(?:" + IRI_LABEL + "\\.)+" + + STRICT_TLD + ")"; + + /** + * Regular expression that matches domain names using either {@link #STRICT_HOST_NAME} or + * {@link #IP_ADDRESS}. + */ + private static final Pattern STRICT_DOMAIN_NAME + = Pattern.compile("(?:" + STRICT_HOST_NAME + "|" + IP_ADDRESS + ")"); + + /** + * Regular expression that matches domain names without a TLD. + */ + private static final String RELAXED_DOMAIN_NAME + = "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")"; + + /** + * Regular expression to match strings that do not start with a supported protocol. The TLDs + * are expected to be one of the known TLDs. + */ + private static final String WEB_URL_WITHOUT_PROTOCOL = "(" + + WORD_BOUNDARY + + "(? + * CollapsibleView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ + +package org.schabi.newpipelegacy.views; + +import android.animation.ValueAnimator; +import android.content.Context; +import android.os.Build; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.Log; +import android.widget.LinearLayout; + +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.schabi.newpipelegacy.util.AnimationUtils; + +import java.lang.annotation.Retention; +import java.util.ArrayList; +import java.util.List; + +import icepick.Icepick; +import icepick.State; + +import static java.lang.annotation.RetentionPolicy.SOURCE; +import static org.schabi.newpipelegacy.MainActivity.DEBUG; + +/** + * A view that can be fully collapsed and expanded. + */ +public class CollapsibleView extends LinearLayout { + private static final String TAG = CollapsibleView.class.getSimpleName(); + + private static final int ANIMATION_DURATION = 420; + + public static final int COLLAPSED = 0; + public static final int EXPANDED = 1; + + @State + @ViewMode + int currentState = COLLAPSED; + private boolean readyToChangeState; + + private int targetHeight = -1; + private ValueAnimator currentAnimator; + private final List listeners = new ArrayList<>(); + + public CollapsibleView(final Context context) { + super(context); + } + + public CollapsibleView(final Context context, @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public CollapsibleView(final Context context, @Nullable final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) + public CollapsibleView(final Context context, final AttributeSet attrs, final int defStyleAttr, + final int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + /*////////////////////////////////////////////////////////////////////////// + // Collapse/expand logic + //////////////////////////////////////////////////////////////////////////*/ + + /** + * This method recalculates the height of this view so it must be called when + * some child changes (e.g. add new views, change text). + */ + public void ready() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() called")); + } + + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), + MeasureSpec.UNSPECIFIED); + targetHeight = getMeasuredHeight(); + + getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight; + requestLayout(); + broadcastState(); + + readyToChangeState = true; + + if (DEBUG) { + Log.d(TAG, getDebugLogString("ready() *after* measuring")); + } + } + + public void collapse() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("collapse() called")); + } + + if (!readyToChangeState) { + return; + } + + final int height = getHeight(); + if (height == 0) { + setCurrentState(COLLAPSED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) { + currentAnimator.cancel(); + } + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, 0); + + setCurrentState(COLLAPSED); + } + + public void expand() { + if (DEBUG) { + Log.d(TAG, getDebugLogString("expand() called")); + } + + if (!readyToChangeState) { + return; + } + + final int height = getHeight(); + if (height == this.targetHeight) { + setCurrentState(EXPANDED); + return; + } + + if (currentAnimator != null && currentAnimator.isRunning()) { + currentAnimator.cancel(); + } + currentAnimator = AnimationUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight); + setCurrentState(EXPANDED); + } + + public void switchState() { + if (!readyToChangeState) { + return; + } + + if (currentState == COLLAPSED) { + expand(); + } else { + collapse(); + } + } + + @ViewMode + public int getCurrentState() { + return currentState; + } + + public void setCurrentState(@ViewMode final int currentState) { + this.currentState = currentState; + broadcastState(); + } + + public void broadcastState() { + for (StateListener listener : listeners) { + listener.onStateChanged(currentState); + } + } + + /** + * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). + * @param listener {@link StateListener} to be added + */ + public void addListener(final StateListener listener) { + if (listeners.contains(listener)) { + throw new IllegalStateException("Trying to add the same listener multiple times"); + } + + listeners.add(listener); + } + + /** + * Remove a listener so it doesn't receive more state changes. + * @param listener {@link StateListener} to be removed + */ + public void removeListener(final StateListener listener) { + listeners.remove(listener); + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Nullable + @Override + public Parcelable onSaveInstanceState() { + return Icepick.saveInstanceState(this, super.onSaveInstanceState()); + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); + + ready(); + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + //////////////////////////////////////////////////////////////////////////*/ + + public String getDebugLogString(final String description) { + return String.format("%-100s → %s", + description, "readyToChangeState = [" + readyToChangeState + "], " + + "currentState = [" + currentState + "], " + + "targetHeight = [" + targetHeight + "], " + + "mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "], " + + "W x H = [" + getWidth() + "x" + getHeight() + "]"); + } + + @Retention(SOURCE) + @IntDef({COLLAPSED, EXPANDED}) + public @interface ViewMode { } + + /** + * Simple interface used for listening state changes of the {@link CollapsibleView}. + */ + public interface StateListener { + /** + * Called when the state changes. + * + * @param newState the state that the {@link CollapsibleView} transitioned to,
+ * it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED} + */ + void onStateChanged(@ViewMode int newState); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareCoordinator.java new file mode 100644 index 000000000..b7fb3a805 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareCoordinator.java @@ -0,0 +1,66 @@ +/* + * Copyright (C) Eltex ltd 2019 + * FocusAwareCoordinator.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.coordinatorlayout.widget.CoordinatorLayout; + +public final class FocusAwareCoordinator extends CoordinatorLayout { + private final Rect childFocus = new Rect(); + + public FocusAwareCoordinator(@NonNull final Context context) { + super(context); + } + + public FocusAwareCoordinator(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public FocusAwareCoordinator(@NonNull final Context context, + @Nullable final AttributeSet attrs, final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void requestChildFocus(final View child, final View focused) { + super.requestChildFocus(child, focused); + + if (!isInTouchMode()) { + if (focused.getHeight() >= getHeight()) { + focused.getFocusedRect(childFocus); + + ((ViewGroup) child).offsetDescendantRectToMyCoords(focused, childFocus); + } else { + focused.getHitRect(childFocus); + + ((ViewGroup) child).offsetDescendantRectToMyCoords((View) focused.getParent(), + childFocus); + } + + requestChildRectangleOnScreen(child, childFocus, false); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareDrawerLayout.java b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareDrawerLayout.java new file mode 100644 index 000000000..e771928aa --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareDrawerLayout.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) Eltex ltd 2019 + * FocusAwareDrawerLayout.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.drawerlayout.widget.DrawerLayout; + +import java.util.ArrayList; + +public final class FocusAwareDrawerLayout extends DrawerLayout { + public FocusAwareDrawerLayout(@NonNull final Context context) { + super(context); + } + + public FocusAwareDrawerLayout(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + } + + public FocusAwareDrawerLayout(@NonNull final Context context, + @Nullable final AttributeSet attrs, + final int defStyle) { + super(context, attrs, defStyle); + } + + @Override + protected boolean onRequestFocusInDescendants(final int direction, + final Rect previouslyFocusedRect) { + // SDK implementation of this method picks whatever visible View takes the focus first + // without regard to addFocusables. If the open drawer is temporarily empty, the focus + // escapes outside of it, which can be confusing + + boolean hasOpenPanels = false; + + for (int i = 0; i < getChildCount(); ++i) { + View child = getChildAt(i); + + DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams(); + + if (lp.gravity != 0 && isDrawerVisible(child)) { + hasOpenPanels = true; + + if (child.requestFocus(direction, previouslyFocusedRect)) { + return true; + } + } + } + + if (hasOpenPanels) { + return false; + } + + return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); + } + + @Override + public void addFocusables(final ArrayList views, final int direction, + final int focusableMode) { + boolean hasOpenPanels = false; + View content = null; + + for (int i = 0; i < getChildCount(); ++i) { + View child = getChildAt(i); + + DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams(); + + if (lp.gravity == 0) { + content = child; + } else { + if (isDrawerVisible(child)) { + hasOpenPanels = true; + child.addFocusables(views, direction, focusableMode); + } + } + } + + if (content != null && !hasOpenPanels) { + content.addFocusables(views, direction, focusableMode); + } + } + + // this override isn't strictly necessary, but it is helpful when DrawerLayout isn't + // the topmost view in hierarchy (such as when system or builtin appcompat ActionBar is used) + @Override + @SuppressLint("RtlHardcoded") + public void openDrawer(@NonNull final View drawerView, final boolean animate) { + super.openDrawer(drawerView, animate); + + drawerView.requestFocus(FOCUS_FORWARD); + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareSeekBar.java new file mode 100644 index 000000000..38e351630 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/FocusAwareSeekBar.java @@ -0,0 +1,147 @@ +/* + * Copyright (C) Eltex ltd 2019 + * FocusAwareDrawerLayout.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.ViewTreeObserver; +import android.widget.SeekBar; + +import androidx.appcompat.widget.AppCompatSeekBar; + +import org.schabi.newpipelegacy.util.AndroidTvUtils; + +/** + * SeekBar, adapted for directional navigation. It emulates touch-related callbacks + * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to + * work with it. + */ +public final class FocusAwareSeekBar extends AppCompatSeekBar { + private NestedListener listener; + + private ViewTreeObserver treeObserver; + + public FocusAwareSeekBar(final Context context) { + super(context); + } + + public FocusAwareSeekBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public FocusAwareSeekBar(final Context context, final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + public void setOnSeekBarChangeListener(final OnSeekBarChangeListener l) { + this.listener = l == null ? null : new NestedListener(l); + + super.setOnSeekBarChangeListener(listener); + } + + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent event) { + if (!isInTouchMode() && AndroidTvUtils.isConfirmKey(keyCode)) { + releaseTrack(); + } + + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onFocusChanged(final boolean gainFocus, final int direction, + final Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + + if (!isInTouchMode() && !gainFocus) { + releaseTrack(); + } + } + + private final ViewTreeObserver.OnTouchModeChangeListener touchModeListener = isInTouchMode -> { + if (isInTouchMode) { + releaseTrack(); + } + }; + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + treeObserver = getViewTreeObserver(); + treeObserver.addOnTouchModeChangeListener(touchModeListener); + } + + @Override + protected void onDetachedFromWindow() { + if (treeObserver == null || !treeObserver.isAlive()) { + treeObserver = getViewTreeObserver(); + } + + treeObserver.removeOnTouchModeChangeListener(touchModeListener); + treeObserver = null; + + super.onDetachedFromWindow(); + } + + private void releaseTrack() { + if (listener != null && listener.isSeeking) { + listener.onStopTrackingTouch(this); + } + } + + private final class NestedListener implements OnSeekBarChangeListener { + private final OnSeekBarChangeListener delegate; + + boolean isSeeking; + + private NestedListener(final OnSeekBarChangeListener delegate) { + this.delegate = delegate; + } + + @Override + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + if (!seekBar.isInTouchMode() && !isSeeking && fromUser) { + isSeeking = true; + + onStartTrackingTouch(seekBar); + } + + delegate.onProgressChanged(seekBar, progress, fromUser); + } + + @Override + public void onStartTrackingTouch(final SeekBar seekBar) { + isSeeking = true; + + delegate.onStartTrackingTouch(seekBar); + } + + @Override + public void onStopTrackingTouch(final SeekBar seekBar) { + isSeeking = false; + + delegate.onStopTrackingTouch(seekBar); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/FocusOverlayView.java b/app/src/main/java/org/schabi/newpipelegacy/views/FocusOverlayView.java new file mode 100644 index 000000000..30c8b8338 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/FocusOverlayView.java @@ -0,0 +1,280 @@ +/* + * Copyright 2019 Alexander Rvachev + * FocusOverlayView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.app.Activity; +import android.app.Dialog; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.ColorFilter; +import android.graphics.Paint; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.view.WindowCallbackWrapper; + +import org.schabi.newpipelegacy.R; + +import java.lang.ref.WeakReference; + +public final class FocusOverlayView extends Drawable implements + ViewTreeObserver.OnGlobalFocusChangeListener, + ViewTreeObserver.OnDrawListener, + ViewTreeObserver.OnGlobalLayoutListener, + ViewTreeObserver.OnScrollChangedListener, ViewTreeObserver.OnTouchModeChangeListener { + + private boolean isInTouchMode; + + private final Rect focusRect = new Rect(); + + private final Paint rectPaint = new Paint(); + + private final Handler animator = new Handler(Looper.getMainLooper()) { + @Override + public void handleMessage(final Message msg) { + updateRect(); + } + }; + + private WeakReference focused; + + public FocusOverlayView(final Context context) { + rectPaint.setStyle(Paint.Style.STROKE); + rectPaint.setStrokeWidth(2); + rectPaint.setColor(context.getResources().getColor(R.color.white)); + } + + @Override + public void onGlobalFocusChanged(final View oldFocus, final View newFocus) { + if (newFocus != null) { + focused = new WeakReference<>(newFocus); + } else { + focused = null; + } + + updateRect(); + + animator.sendEmptyMessageDelayed(0, 1000); + } + + private void updateRect() { + View focusedView = focused == null ? null : this.focused.get(); + + int l = focusRect.left; + int r = focusRect.right; + int t = focusRect.top; + int b = focusRect.bottom; + + if (focusedView != null && isShown(focusedView)) { + focusedView.getGlobalVisibleRect(focusRect); + } else { + focusRect.setEmpty(); + } + + if (l != focusRect.left || r != focusRect.right + || t != focusRect.top || b != focusRect.bottom) { + invalidateSelf(); + } + } + + private boolean isShown(@NonNull final View view) { + return view.getWidth() != 0 && view.getHeight() != 0 && view.isShown(); + } + + @Override + public void onDraw() { + updateRect(); + } + + @Override + public void onScrollChanged() { + updateRect(); + + animator.removeMessages(0); + animator.sendEmptyMessageDelayed(0, 1000); + } + + @Override + public void onGlobalLayout() { + updateRect(); + + animator.sendEmptyMessageDelayed(0, 1000); + } + + @Override + public void onTouchModeChanged(final boolean inTouchMode) { + this.isInTouchMode = inTouchMode; + + if (inTouchMode) { + updateRect(); + } else { + invalidateSelf(); + } + } + + public void setCurrentFocus(final View newFocus) { + if (newFocus == null) { + return; + } + + this.isInTouchMode = newFocus.isInTouchMode(); + + onGlobalFocusChanged(null, newFocus); + } + + @Override + public void draw(@NonNull final Canvas canvas) { + if (!isInTouchMode && focusRect.width() != 0) { + canvas.drawRect(focusRect, rectPaint); + } + } + + @Override + public int getOpacity() { + return PixelFormat.TRANSPARENT; + } + + @Override + public void setAlpha(final int alpha) { + } + + @Override + public void setColorFilter(final ColorFilter colorFilter) { + } + + public static void setupFocusObserver(final Dialog dialog) { + Rect displayRect = new Rect(); + + Window window = dialog.getWindow(); + assert window != null; + + View decor = window.getDecorView(); + decor.getWindowVisibleDisplayFrame(displayRect); + + FocusOverlayView overlay = new FocusOverlayView(dialog.getContext()); + overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); + + setupOverlay(window, overlay); + } + + public static void setupFocusObserver(final Activity activity) { + Rect displayRect = new Rect(); + + Window window = activity.getWindow(); + View decor = window.getDecorView(); + decor.getWindowVisibleDisplayFrame(displayRect); + + FocusOverlayView overlay = new FocusOverlayView(activity); + overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); + + setupOverlay(window, overlay); + } + + private static void setupOverlay(final Window window, final FocusOverlayView overlay) { + ViewGroup decor = (ViewGroup) window.getDecorView(); + decor.getOverlay().add(overlay); + + fixFocusHierarchy(decor); + + ViewTreeObserver observer = decor.getViewTreeObserver(); + observer.addOnScrollChangedListener(overlay); + observer.addOnGlobalFocusChangeListener(overlay); + observer.addOnGlobalLayoutListener(overlay); + observer.addOnTouchModeChangeListener(overlay); + observer.addOnDrawListener(overlay); + + overlay.setCurrentFocus(decor.getFocusedChild()); + + // Some key presses don't actually move focus, but still result in movement on screen. + // For example, MovementMethod of TextView may cause requestRectangleOnScreen() due to + // some "focusable" spans, which in turn causes CoordinatorLayout to "scroll" it's children. + // Unfortunately many such forms of "scrolling" do not count as scrolling for purpose + // of dispatching ViewTreeObserver callbacks, so we have to intercept them by directly + // receiving keys from Window. + window.setCallback(new WindowCallbackWrapper(window.getCallback()) { + @Override + public boolean dispatchKeyEvent(final KeyEvent event) { + boolean res = super.dispatchKeyEvent(event); + overlay.onKey(event); + return res; + } + }); + } + + private void onKey(final KeyEvent event) { + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return; + } + + updateRect(); + + animator.sendEmptyMessageDelayed(0, 100); + } + + private static void fixFocusHierarchy(final View decor) { + // During Android 8 development some dumb ass decided, that action bar has to be + // a keyboard focus cluster. Unfortunately, keyboard clusters do not work for primary + // auditory of key navigation — Android TV users (Android TV remotes do not have + // keyboard META key for moving between clusters). We have to fix this unfortunate accident + // While we are at it, let's deal with touchscreenBlocksFocus too. + + if (Build.VERSION.SDK_INT < 26) { + return; + } + + if (!(decor instanceof ViewGroup)) { + return; + } + + clearFocusObstacles((ViewGroup) decor); + } + + @RequiresApi(api = 26) + private static void clearFocusObstacles(final ViewGroup viewGroup) { + viewGroup.setTouchscreenBlocksFocus(false); + + if (viewGroup.isKeyboardNavigationCluster()) { + viewGroup.setKeyboardNavigationCluster(false); + + return; // clusters aren't supposed to nest + } + + int childCount = viewGroup.getChildCount(); + + for (int i = 0; i < childCount; ++i) { + View view = viewGroup.getChildAt(i); + + if (view instanceof ViewGroup) { + clearFocusObstacles((ViewGroup) view); + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/LargeTextMovementMethod.java b/app/src/main/java/org/schabi/newpipelegacy/views/LargeTextMovementMethod.java new file mode 100644 index 000000000..6b1f29438 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/LargeTextMovementMethod.java @@ -0,0 +1,303 @@ +/* + * Copyright 2019 Alexander Rvachev + * FocusOverlayView.java is part of NewPipe + * + * License: GPL-3.0+ + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.graphics.Rect; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.widget.TextView; + +public class LargeTextMovementMethod extends LinkMovementMethod { + private final Rect visibleRect = new Rect(); + + private int direction; + + @Override + public void onTakeFocus(final TextView view, final Spannable text, final int dir) { + Selection.removeSelection(text); + + super.onTakeFocus(view, text, dir); + + this.direction = dirToRelative(dir); + } + + @Override + protected boolean handleMovementKey(final TextView widget, + final Spannable buffer, + final int keyCode, + final int movementMetaState, + final KeyEvent event) { + if (!doHandleMovement(widget, buffer, keyCode, movementMetaState, event)) { + // clear selection to make sure, that it does not confuse focus handling code + Selection.removeSelection(buffer); + return false; + } + + return true; + } + + private boolean doHandleMovement(final TextView widget, + final Spannable buffer, + final int keyCode, + final int movementMetaState, + final KeyEvent event) { + int newDir = keyToDir(keyCode); + + if (direction != 0 && newDir != direction) { + return false; + } + + this.direction = 0; + + ViewGroup root = findScrollableParent(widget); + + widget.getHitRect(visibleRect); + + root.offsetDescendantRectToMyCoords((View) widget.getParent(), visibleRect); + + return super.handleMovementKey(widget, buffer, keyCode, movementMetaState, event); + } + + @Override + protected boolean up(final TextView widget, final Spannable buffer) { + if (gotoPrev(widget, buffer)) { + return true; + } + + return super.up(widget, buffer); + } + + @Override + protected boolean left(final TextView widget, final Spannable buffer) { + if (gotoPrev(widget, buffer)) { + return true; + } + + return super.left(widget, buffer); + } + + @Override + protected boolean right(final TextView widget, final Spannable buffer) { + if (gotoNext(widget, buffer)) { + return true; + } + + return super.right(widget, buffer); + } + + @Override + protected boolean down(final TextView widget, final Spannable buffer) { + if (gotoNext(widget, buffer)) { + return true; + } + + return super.down(widget, buffer); + } + + private boolean gotoPrev(final TextView view, final Spannable buffer) { + Layout layout = view.getLayout(); + if (layout == null) { + return false; + } + + View root = findScrollableParent(view); + + int rootHeight = root.getHeight(); + + if (visibleRect.top >= 0) { + // we fit entirely into the viewport, no need for fancy footwork + return false; + } + + int topExtra = -visibleRect.top; + + int firstVisibleLineNumber = layout.getLineForVertical(topExtra); + + // when deciding whether to pass "focus" to span, account for one more line + // this ensures, that focus is never passed to spans partially outside scroll window + int visibleStart = firstVisibleLineNumber == 0 + ? 0 + : layout.getLineStart(firstVisibleLineNumber - 1); + + ClickableSpan[] candidates = buffer.getSpans( + visibleStart, buffer.length(), ClickableSpan.class); + + if (candidates.length != 0) { + int a = Selection.getSelectionStart(buffer); + int b = Selection.getSelectionEnd(buffer); + + int selStart = Math.min(a, b); + int selEnd = Math.max(a, b); + + int bestStart = -1; + int bestEnd = -1; + + for (int i = 0; i < candidates.length; i++) { + int start = buffer.getSpanStart(candidates[i]); + int end = buffer.getSpanEnd(candidates[i]); + + if ((end < selEnd || selStart == selEnd) && start >= visibleStart) { + if (end > bestEnd) { + bestStart = buffer.getSpanStart(candidates[i]); + bestEnd = end; + } + } + } + + if (bestStart >= 0) { + Selection.setSelection(buffer, bestEnd, bestStart); + return true; + } + } + + float fourLines = view.getTextSize() * 4; + + visibleRect.left = 0; + visibleRect.right = view.getWidth(); + visibleRect.top = Math.max(0, (int) (topExtra - fourLines)); + visibleRect.bottom = visibleRect.top + rootHeight; + + return view.requestRectangleOnScreen(visibleRect); + } + + private boolean gotoNext(final TextView view, final Spannable buffer) { + Layout layout = view.getLayout(); + if (layout == null) { + return false; + } + + View root = findScrollableParent(view); + + int rootHeight = root.getHeight(); + + if (visibleRect.bottom <= rootHeight) { + // we fit entirely into the viewport, no need for fancy footwork + return false; + } + + int bottomExtra = visibleRect.bottom - rootHeight; + + int visibleBottomBorder = view.getHeight() - bottomExtra; + + int lineCount = layout.getLineCount(); + + int lastVisibleLineNumber = layout.getLineForVertical(visibleBottomBorder); + + // when deciding whether to pass "focus" to span, account for one more line + // this ensures, that focus is never passed to spans partially outside scroll window + int visibleEnd = lastVisibleLineNumber == lineCount - 1 + ? buffer.length() + : layout.getLineEnd(lastVisibleLineNumber - 1); + + ClickableSpan[] candidates = buffer.getSpans(0, visibleEnd, ClickableSpan.class); + + if (candidates.length != 0) { + int a = Selection.getSelectionStart(buffer); + int b = Selection.getSelectionEnd(buffer); + + int selStart = Math.min(a, b); + int selEnd = Math.max(a, b); + + int bestStart = Integer.MAX_VALUE; + int bestEnd = Integer.MAX_VALUE; + + for (int i = 0; i < candidates.length; i++) { + int start = buffer.getSpanStart(candidates[i]); + int end = buffer.getSpanEnd(candidates[i]); + + if ((start > selStart || selStart == selEnd) && end <= visibleEnd) { + if (start < bestStart) { + bestStart = start; + bestEnd = buffer.getSpanEnd(candidates[i]); + } + } + } + + if (bestEnd < Integer.MAX_VALUE) { + // cool, we have managed to find next link without having to adjust self within view + Selection.setSelection(buffer, bestStart, bestEnd); + return true; + } + } + + // there are no links within visible area, but still some text past visible area + // scroll visible area further in required direction + float fourLines = view.getTextSize() * 4; + + visibleRect.left = 0; + visibleRect.right = view.getWidth(); + visibleRect.bottom = Math.min((int) (visibleBottomBorder + fourLines), view.getHeight()); + visibleRect.top = visibleRect.bottom - rootHeight; + + return view.requestRectangleOnScreen(visibleRect); + } + + private ViewGroup findScrollableParent(final View view) { + View current = view; + + ViewParent parent; + do { + parent = current.getParent(); + + if (parent == current || !(parent instanceof View)) { + return (ViewGroup) view.getRootView(); + } + + current = (View) parent; + + if (current.isScrollContainer()) { + return (ViewGroup) current; + } + } + while (true); + } + + private static int dirToRelative(final int dir) { + switch (dir) { + case View.FOCUS_DOWN: + case View.FOCUS_RIGHT: + return View.FOCUS_FORWARD; + case View.FOCUS_UP: + case View.FOCUS_LEFT: + return View.FOCUS_BACKWARD; + } + + return dir; + } + + private int keyToDir(final int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_LEFT: + return View.FOCUS_BACKWARD; + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_DPAD_RIGHT: + return View.FOCUS_FORWARD; + } + + return View.FOCUS_FORWARD; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/NewPipeRecyclerView.java b/app/src/main/java/org/schabi/newpipelegacy/views/NewPipeRecyclerView.java new file mode 100644 index 000000000..400d20f2c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/NewPipeRecyclerView.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) Eltex ltd 2019 + * NewPipeRecyclerView.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Build; +import android.util.AttributeSet; +import android.util.Log; +import android.view.FocusFinder; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.RecyclerView; + +public class NewPipeRecyclerView extends RecyclerView { + private static final String TAG = "NewPipeRecyclerView"; + + private Rect focusRect = new Rect(); + private Rect tempFocus = new Rect(); + + private boolean allowDpadScroll = true; + + public NewPipeRecyclerView(@NonNull final Context context) { + super(context); + + init(); + } + + public NewPipeRecyclerView(@NonNull final Context context, + @Nullable final AttributeSet attrs) { + super(context, attrs); + + init(); + } + + public NewPipeRecyclerView(@NonNull final Context context, + @Nullable final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + init(); + } + + private void init() { + setFocusable(true); + + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + } + + public void setFocusScrollAllowed(final boolean allowed) { + this.allowDpadScroll = allowed; + } + + @Override + public View focusSearch(final View focused, final int direction) { + // RecyclerView has buggy focusSearch(), that calls into Adapter several times, + // but ultimately fails to produce correct results in many cases. To add insult to injury, + // it's focusSearch() hard-codes several behaviors, incompatible with widely accepted focus + // handling practices: RecyclerView does not allow Adapter to give focus to itself (!!) and + // always checks, that returned View is located in "correct" direction (which prevents us + // from temporarily giving focus to special hidden View). + return null; + } + + @Override + protected void removeDetachedView(final View child, final boolean animate) { + if (child.hasFocus()) { + // If the focused child is being removed (can happen during very fast scrolling), + // temporarily give focus to ourselves. This will usually result in another child + // gaining focus (which one does not really matter, because at that point scrolling + // is FAST, and that child will soon be off-screen too) + requestFocus(); + } + + super.removeDetachedView(child, animate); + } + + // we override focusSearch to always return null, so all moves moves lead to + // dispatchUnhandledMove(). As added advantage, we can fully swallow some kinds of moves + // (such as downward movement, that happens when loading additional contents is in progress + + @Override + public boolean dispatchUnhandledMove(final View focused, final int direction) { + tempFocus.setEmpty(); + + // save focus rect before further manipulation (both focusSearch() and scrollBy() + // can mess with focused View by moving it off-screen and detaching) + + if (focused != null) { + View focusedItem = findContainingItemView(focused); + if (focusedItem != null) { + focusedItem.getHitRect(focusRect); + } + } + + // call focusSearch() to initiate layout, but disregard returned View for now + View adapterResult = super.focusSearch(focused, direction); + if (adapterResult != null && !isOutside(adapterResult)) { + adapterResult.requestFocus(direction); + return true; + } + + if (arrowScroll(direction)) { + // if RecyclerView can not yield focus, but there is still some scrolling space in + // indicated, direction, scroll some fixed amount in that direction + // (the same logic in ScrollView) + return true; + } + + if (focused != this && direction == FOCUS_DOWN && !allowDpadScroll) { + Log.i(TAG, "Consuming downward scroll: content load in progress"); + return true; + } + + if (tryFocusFinder(direction)) { + return true; + } + + if (adapterResult != null) { + adapterResult.requestFocus(direction); + return true; + } + + return super.dispatchUnhandledMove(focused, direction); + } + + private boolean tryFocusFinder(final int direction) { + if (Build.VERSION.SDK_INT >= 28) { + // Android 9 implemented bunch of handy changes to focus, that render code below less + // useful, and also broke findNextFocusFromRect in way, that render this hack useless + return false; + } + + FocusFinder finder = FocusFinder.getInstance(); + + // try to use FocusFinder instead of adapter + ViewGroup root = (ViewGroup) getRootView(); + + tempFocus.set(focusRect); + + root.offsetDescendantRectToMyCoords(this, tempFocus); + + View focusFinderResult = finder.findNextFocusFromRect(root, tempFocus, direction); + if (focusFinderResult != null && !isOutside(focusFinderResult)) { + focusFinderResult.requestFocus(direction); + return true; + } + + // look for focus in our ancestors, increasing search scope with each failure + // this provides much better locality than using FocusFinder with root + ViewGroup parent = (ViewGroup) getParent(); + + while (parent != root) { + tempFocus.set(focusRect); + + parent.offsetDescendantRectToMyCoords(this, tempFocus); + + View candidate = finder.findNextFocusFromRect(parent, tempFocus, direction); + if (candidate != null && candidate.requestFocus(direction)) { + return true; + } + + parent = (ViewGroup) parent.getParent(); + } + + return false; + } + + private boolean arrowScroll(final int direction) { + switch (direction) { + case FOCUS_DOWN: + if (!canScrollVertically(1)) { + return false; + } + scrollBy(0, 100); + break; + case FOCUS_UP: + if (!canScrollVertically(-1)) { + return false; + } + scrollBy(0, -100); + break; + case FOCUS_LEFT: + if (!canScrollHorizontally(-1)) { + return false; + } + scrollBy(-100, 0); + break; + case FOCUS_RIGHT: + if (!canScrollHorizontally(-1)) { + return false; + } + scrollBy(100, 0); + break; + default: + return false; + } + + return true; + } + + private boolean isOutside(final View view) { + return findContainingItemView(view) == null; + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/ScrollableTabLayout.java b/app/src/main/java/org/schabi/newpipelegacy/views/ScrollableTabLayout.java new file mode 100644 index 000000000..9fa7bb72e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/ScrollableTabLayout.java @@ -0,0 +1,134 @@ +package org.schabi.newpipelegacy.views; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.google.android.material.tabs.TabLayout; + +/** + * A TabLayout that is scrollable when tabs exceed its width. + * Hides when there are less than 2 tabs. + */ +public class ScrollableTabLayout extends TabLayout { + private static final String TAG = ScrollableTabLayout.class.getSimpleName(); + + private int layoutWidth = 0; + private int prevVisibility = View.GONE; + + public ScrollableTabLayout(final Context context) { + super(context); + } + + public ScrollableTabLayout(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + public ScrollableTabLayout(final Context context, final AttributeSet attrs, + final int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onLayout(final boolean changed, final int l, final int t, final int r, + final int b) { + super.onLayout(changed, l, t, r, b); + + remeasureTabs(); + } + + @Override + protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { + super.onSizeChanged(w, h, oldw, oldh); + + layoutWidth = w; + } + + @Override + public void addTab(@NonNull final Tab tab, final int position, final boolean setSelected) { + super.addTab(tab, position, setSelected); + + hasMultipleTabs(); + + // Adding a tab won't decrease total tabs' width so tabMode won't have to change to FIXED + if (getTabMode() != MODE_SCROLLABLE) { + remeasureTabs(); + } + } + + @Override + public void removeTabAt(final int position) { + super.removeTabAt(position); + + hasMultipleTabs(); + + // Removing a tab won't increase total tabs' width + // so tabMode won't have to change to SCROLLABLE + if (getTabMode() != MODE_FIXED) { + remeasureTabs(); + } + } + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + + // Check width if some tabs have been added/removed while ScrollableTabLayout was invisible + // We don't have to check if it was GONE because then requestLayout() will be called + if (changedView == this) { + if (prevVisibility == View.INVISIBLE) { + remeasureTabs(); + } + prevVisibility = visibility; + } + } + + private void setMode(final int mode) { + if (mode == getTabMode()) { + return; + } + + setTabMode(mode); + } + + /** + * Make ScrollableTabLayout not visible if there are less than two tabs. + */ + private void hasMultipleTabs() { + if (getTabCount() > 1) { + setVisibility(View.VISIBLE); + } else { + setVisibility(View.GONE); + } + } + + /** + * Calculate minimal width required by tabs and set tabMode accordingly. + */ + private void remeasureTabs() { + if (prevVisibility != View.VISIBLE) { + return; + } + if (layoutWidth == 0) { + return; + } + + final int count = getTabCount(); + int contentWidth = 0; + for (int i = 0; i < count; i++) { + View child = getTabAt(i).view; + if (child.getVisibility() == View.VISIBLE) { + // Use tab's minimum requested width should actual content be too small + contentWidth += Math.max(child.getMinimumWidth(), child.getMeasuredWidth()); + } + } + + if (contentWidth > layoutWidth) { + setMode(TabLayout.MODE_SCROLLABLE); + } else { + setMode(TabLayout.MODE_FIXED); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipelegacy/views/SuperScrollLayoutManager.java b/app/src/main/java/org/schabi/newpipelegacy/views/SuperScrollLayoutManager.java new file mode 100644 index 000000000..7cecc3415 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipelegacy/views/SuperScrollLayoutManager.java @@ -0,0 +1,174 @@ +/* + * Copyright (C) Eltex ltd 2019 + * SuperScrollLayoutManager.java is part of NewPipe. + * + * NewPipe is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * NewPipe is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with NewPipe. If not, see . + */ +package org.schabi.newpipelegacy.views; + +import android.content.Context; +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; + +public final class SuperScrollLayoutManager extends LinearLayoutManager { + private final Rect handy = new Rect(); + + private final ArrayList focusables = new ArrayList<>(); + + public SuperScrollLayoutManager(final Context context) { + super(context); + } + + @Override + public boolean requestChildRectangleOnScreen(@NonNull final RecyclerView parent, + @NonNull final View child, + @NonNull final Rect rect, + final boolean immediate, + final boolean focusedChildVisible) { + if (!parent.isInTouchMode()) { + // only activate when in directional navigation mode (Android TV etc) — fine grained + // touch scrolling is better served by nested scroll system + + if (!focusedChildVisible || getFocusedChild() == child) { + handy.set(rect); + + parent.offsetDescendantRectToMyCoords(child, handy); + + parent.requestRectangleOnScreen(handy, immediate); + } + } + + return super.requestChildRectangleOnScreen(parent, child, rect, immediate, + focusedChildVisible); + } + + @Nullable + @Override + public View onInterceptFocusSearch(@NonNull final View focused, final int direction) { + View focusedItem = findContainingItemView(focused); + if (focusedItem == null) { + return super.onInterceptFocusSearch(focused, direction); + } + + int listDirection = getAbsoluteDirection(direction); + if (listDirection == 0) { + return super.onInterceptFocusSearch(focused, direction); + } + + // FocusFinder has an oddity: it considers size of Views more important + // than closeness to source View. This means, that big Views far away from current item + // are preferred to smaller sub-View of closer item. Setting focusability of closer item + // to FOCUS_AFTER_DESCENDANTS does not solve this, because ViewGroup#addFocusables omits + // such parent itself from list, if any of children are focusable. + // Fortunately we can intercept focus search and implement our own logic, based purely + // on position along the LinearLayoutManager axis + + ViewGroup recycler = (ViewGroup) focusedItem.getParent(); + + int sourcePosition = getPosition(focusedItem); + if (sourcePosition == 0 && listDirection < 0) { + return super.onInterceptFocusSearch(focused, direction); + } + + View preferred = null; + + int distance = Integer.MAX_VALUE; + + focusables.clear(); + + recycler.addFocusables(focusables, direction, recycler.isInTouchMode() + ? View.FOCUSABLES_TOUCH_MODE + : View.FOCUSABLES_ALL); + + try { + for (View view : focusables) { + if (view == focused || view == recycler) { + continue; + } + + if (view == focusedItem) { + // do not pass focus back to the item View itself - it makes no sense + // (we can still pass focus to it's children however) + continue; + } + + int candidate = getDistance(sourcePosition, view, listDirection); + if (candidate < 0) { + continue; + } + + if (candidate < distance) { + distance = candidate; + preferred = view; + } + } + } finally { + focusables.clear(); + } + + return preferred; + } + + private int getAbsoluteDirection(final int direction) { + switch (direction) { + default: + break; + case View.FOCUS_FORWARD: + return 1; + case View.FOCUS_BACKWARD: + return -1; + } + + if (getOrientation() == RecyclerView.HORIZONTAL) { + switch (direction) { + default: + break; + case View.FOCUS_LEFT: + return getReverseLayout() ? 1 : -1; + case View.FOCUS_RIGHT: + return getReverseLayout() ? -1 : 1; + } + } else { + switch (direction) { + default: + break; + case View.FOCUS_UP: + return getReverseLayout() ? 1 : -1; + case View.FOCUS_DOWN: + return getReverseLayout() ? -1 : 1; + } + } + + return 0; + } + + private int getDistance(final int sourcePosition, final View candidate, final int direction) { + View itemView = findContainingItemView(candidate); + if (itemView == null) { + return -1; + } + + int position = getPosition(itemView); + + return direction * (position - sourcePosition); + } +} 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..8647585da --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -0,0 +1,208 @@ +package us.shandian.giga.get; + +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; + +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; + +public class DownloadInitializer extends Thread { + private final static String TAG = "DownloadInitializer"; + final static int mId = 0; + private final static int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB + private final static int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB + + private DownloadMission mMission; + private HttpURLConnection mConn; + + DownloadInitializer(@NonNull DownloadMission mission) { + mMission = mission; + mConn = null; + } + + private void dispose() { + try { + mConn.getInputStream().close(); + } catch (Exception e) { + // nothing to do + } + } + + @Override + public void run() { + if (mMission.current > 0) mMission.resetState(false, true, DownloadMission.ERROR_NOTHING); + + int retryCount = 0; + int httpCode = 204; + + while (true) { + try { + if (mMission.blocks == null && mMission.current == 0) { + // calculate the whole size of the mission + long finalLength = 0; + long lowestSize = Long.MAX_VALUE; + + for (int i = 0; i < mMission.urls.length && mMission.running; i++) { + mConn = mMission.openConnection(mMission.urls[i], true, -1, -1); + mMission.establishConnection(mId, mConn); + dispose(); + + if (Thread.interrupted()) return; + long length = Utility.getContentLength(mConn); + + if (i == 0) { + httpCode = mConn.getResponseCode(); + mMission.length = length; + } + + if (length > 0) finalLength += length; + if (length < lowestSize) lowestSize = length; + } + + mMission.nearLength = finalLength; + + // reserve space at the start of the file + if (mMission.psAlgorithm != null && mMission.psAlgorithm.reserveSpace) { + if (lowestSize < 1) { + // the length is unknown use the default size + mMission.offsets[0] = RESERVE_SPACE_DEFAULT; + } else { + // use the smallest resource size to download, otherwise, use the maximum + mMission.offsets[0] = lowestSize < RESERVE_SPACE_MAXIMUM ? lowestSize : RESERVE_SPACE_MAXIMUM; + } + } + } else { + // ask for the current resource length + mConn = mMission.openConnection(true, -1, -1); + mMission.establishConnection(mId, mConn); + dispose(); + + if (!mMission.running || Thread.interrupted()) return; + + httpCode = mConn.getResponseCode(); + mMission.length = Utility.getContentLength(mConn); + } + + if (mMission.length == 0 || httpCode == 204) { + mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); + return; + } + + // check for dynamic generated content + if (mMission.length == -1 && mConn.getResponseCode() == 200) { + mMission.blocks = new int[0]; + mMission.length = 0; + mMission.unknownLength = true; + + if (DEBUG) { + Log.d(TAG, "falling back (unknown length)"); + } + } else { + // Open again + mConn = mMission.openConnection(true, mMission.length - 10, mMission.length); + mMission.establishConnection(mId, mConn); + dispose(); + + if (!mMission.running || Thread.interrupted()) return; + + synchronized (mMission.LOCK) { + if (mConn.getResponseCode() == 206) { + + if (mMission.threadCount > 1) { + int count = (int) (mMission.length / DownloadMission.BLOCK_SIZE); + if ((count * DownloadMission.BLOCK_SIZE) < mMission.length) count++; + + mMission.blocks = new int[count]; + } else { + // if one thread is required don't calculate blocks, is useless + mMission.blocks = new int[0]; + mMission.unknownLength = false; + } + + if (DEBUG) { + Log.d(TAG, "http response code = " + mConn.getResponseCode()); + } + } else { + // Fallback to single thread + mMission.blocks = new int[0]; + mMission.unknownLength = false; + + if (DEBUG) { + Log.d(TAG, "falling back due http response code = " + mConn.getResponseCode()); + } + } + } + + if (!mMission.running || Thread.interrupted()) return; + } + + SharpStream fs = mMission.storage.getStream(); + fs.setLength(mMission.offsets[mMission.current] + mMission.length); + fs.seek(mMission.offsets[mMission.current]); + fs.close(); + + if (!mMission.running || Thread.interrupted()) return; + + if (!mMission.unknownLength && mMission.recoveryInfo != null) { + String entityTag = mConn.getHeaderField("ETAG"); + String lastModified = mConn.getHeaderField("Last-Modified"); + MissionRecoveryInfo recovery = mMission.recoveryInfo[mMission.current]; + + if (!TextUtils.isEmpty(entityTag)) { + recovery.validateCondition = entityTag; + } else if (!TextUtils.isEmpty(lastModified)) { + recovery.validateCondition = lastModified;// Note: this is less precise + } else { + recovery.validateCondition = null; + } + } + + mMission.running = false; + break; + } catch (InterruptedIOException | ClosedByInterruptException e) { + return; + } catch (Exception e) { + if (!mMission.running || super.isInterrupted()) return; + + if (e instanceof DownloadMission.HttpError && ((DownloadMission.HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired + interrupt(); + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + return; + } + + if (e instanceof IOException && e.getMessage().contains("Permission denied")) { + mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); + return; + } + + if (retryCount++ > mMission.maxRetry) { + Log.e(TAG, "initializer failed", e); + mMission.notifyError(e); + return; + } + + Log.e(TAG, "initializer failed, retrying", e); + } + } + + mMission.start(); + } + + @Override + public void interrupt() { + super.interrupt(); + if (mConn != null) dispose(); + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java new file mode 100644 index 000000000..0173399c6 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -0,0 +1,855 @@ +package us.shandian.giga.get; + +import android.os.Build; +import android.os.Handler; +import android.system.ErrnoException; +import android.system.OsConstants; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipelegacy.DownloaderImpl; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.io.Serializable; +import java.net.ConnectException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.net.UnknownHostException; +import java.nio.channels.ClosedByInterruptException; + +import javax.net.ssl.SSLException; + +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; + +public class DownloadMission extends Mission { + private static final long serialVersionUID = 6L;// last bump: 07 october 2019 + + static final int BUFFER_SIZE = 64 * 1024; + static final int BLOCK_SIZE = 512 * 1024; + + private static final String TAG = "DownloadMission"; + + 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 = 1007; + public static final int ERROR_POSTPROCESSING_STOPPED = 1008; + public static final int ERROR_POSTPROCESSING_HOLD = 1009; + public static final int ERROR_INSUFFICIENT_STORAGE = 1010; + public static final int ERROR_PROGRESS_LOST = 1011; + public static final int ERROR_TIMEOUT = 1012; + public static final int ERROR_RESOURCE_GONE = 1013; + public static final int ERROR_HTTP_NO_CONTENT = 204; + static final int ERROR_HTTP_FORBIDDEN = 403; + + /** + * The urls of the file to download + */ + public String[] urls; + + /** + * Number of bytes downloaded and written + */ + public volatile 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; + + /** + * Indicates if the post-processing state: + * 0: ready + * 1: running + * 2: completed + * 3: hold + */ + public volatile int psState; + + /** + * the post-processing algorithm instance + */ + public Postprocessing psAlgorithm; + + /** + * The current resource to download, {@code urls[current]} and {@code offsets[current]} + */ + public int current; + + /** + * Metadata where the mission state is saved + */ + public transient File metadata; + + /** + * maximum attempts + */ + public transient int maxRetry; + + /** + * Approximated final length, this represent the sum of all resources sizes + */ + public long nearLength; + + /** + * Download blocks, the size is multiple of {@link DownloadMission#BLOCK_SIZE}. + * Every entry (block) in this array holds an offset, used to resume the download. + * An block offset can be -1 if the block was downloaded successfully. + */ + int[] blocks; + + /** + * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} + */ + volatile long fallbackResumeOffset; + + /** + * Maximum of download threads running, chosen by the user + */ + public int threadCount = 3; + + /** + * information required to recover a download + */ + public MissionRecoveryInfo[] recoveryInfo; + + private transient int finishCount; + public transient volatile boolean running; + public boolean enqueued; + + public int errCode = ERROR_NOTHING; + public Exception errObject = null; + + public transient Handler mHandler; + private transient boolean[] blockAcquired; + + private transient long writingToFileNext; + private transient volatile boolean writingToFile; + + final Object LOCK = new Lock(); + + @NonNull + public transient Thread[] threads = new Thread[0]; + public transient Thread init = null; + + public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { + if (urls == null) throw new NullPointerException("urls is null"); + if (urls.length < 1) throw new IllegalArgumentException("urls is empty"); + this.urls = urls; + this.kind = kind; + this.offsets = new long[urls.length]; + this.enqueued = true; + this.maxRetry = 3; + this.storage = storage; + this.psAlgorithm = psInstance; + + if (DEBUG && psInstance == null && urls.length > 1) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); + } + } + + /** + * Acquire a block + * + * @return the block or {@code null} if no more blocks left + */ + @Nullable + Block acquireBlock() { + synchronized (LOCK) { + for (int i = 0; i < blockAcquired.length; i++) { + if (!blockAcquired[i] && blocks[i] >= 0) { + Block block = new Block(); + block.position = i; + block.done = blocks[i]; + + blockAcquired[i] = true; + return block; + } + } + } + + return null; + } + + /** + * Release an block + * + * @param position the index of the block + * @param done amount of bytes downloaded + */ + void releaseBlock(int position, int done) { + synchronized (LOCK) { + blockAcquired[position] = false; + blocks[position] = done; + } + } + + /** + * Opens a connection + * + * @param headRequest {@code true} for use {@code HEAD} request method, otherwise, {@code GET} is used + * @param rangeStart range start + * @param rangeEnd range end + * @return a {@link java.net.URLConnection URLConnection} linking to the URL. + * @throws IOException if an I/O exception occurs. + */ + HttpURLConnection openConnection(boolean headRequest, long rangeStart, long rangeEnd) throws IOException { + return openConnection(urls[current], headRequest, rangeStart, rangeEnd); + } + + HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setInstanceFollowRedirects(true); + conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); + conn.setRequestProperty("Accept", "*/*"); + conn.setRequestProperty("Accept-Encoding", "*"); + + if (headRequest) conn.setRequestMethod("HEAD"); + + // BUG workaround: switching between networks can freeze the download forever + conn.setConnectTimeout(30000); + + if (rangeStart >= 0) { + String req = "bytes=" + rangeStart + "-"; + if (rangeEnd > 0) req += rangeEnd; + + conn.setRequestProperty("Range", req); + } + + 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 { + int statusCode = conn.getResponseCode(); + + if (DEBUG) { + Log.d(TAG, threadId + ":[request] Range=" + conn.getRequestProperty("Range")); + Log.d(TAG, threadId + ":[response] Code=" + statusCode); + Log.d(TAG, threadId + ":[response] Content-Length=" + conn.getContentLength()); + Log.d(TAG, threadId + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")); + } + + + switch (statusCode) { + case 204: + case 205: + case 207: + throw new HttpError(statusCode); + 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) { + mHandler.obtainMessage(what, this).sendToTarget(); + } + + synchronized void notifyProgress(long deltaLen) { + if (unknownLength) { + length += deltaLen;// Update length before proceeding + } + + done += deltaLen; + + if (metadata == null) return; + + if (!writingToFile && (done > writingToFileNext || deltaLen < 0)) { + writingToFile = true; + writingToFileNext = done + BLOCK_SIZE; + writeThisToFileAsync(); + } + } + + 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 if (err instanceof SocketTimeoutException) { + notifyError(ERROR_TIMEOUT, null); + } else { + notifyError(ERROR_UNKNOWN_EXCEPTION, err); + } + } + + public synchronized void notifyError(int code, Exception err) { + Log.e(TAG, "notifyError() code = " + code, err); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (err != null && err.getCause() instanceof ErrnoException) { + int errno = ((ErrnoException) err.getCause()).errno; + if (errno == OsConstants.ENOSPC) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (errno == OsConstants.EACCES) { + code = ERROR_PERMISSION_DENIED; + err = null; + } + } + } + + if (err instanceof IOException) { + if (err.getMessage().contains("Permission denied")) { + code = ERROR_PERMISSION_DENIED; + err = null; + } else if (err.getMessage().contains("ENOSPC")) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (!storage.canWrite()) { + code = ERROR_FILE_CREATION; + err = null; + } + } + + errCode = code; + errObject = err; + + switch (code) { + case ERROR_SSL_EXCEPTION: + case ERROR_UNKNOWN_HOST: + case ERROR_CONNECT_HOST: + case ERROR_TIMEOUT: + // do not change the queue flag for network errors, can be + // recovered silently without the user interaction + break; + default: + // also checks for server errors + if (code < 500 || code > 599) enqueued = false; + } + + notify(DownloadManagerService.MESSAGE_ERROR); + + if (running) pauseThreads(); + } + + synchronized void notifyFinished() { + if (current < urls.length) { + if (++finishCount < threads.length) return; + + if (DEBUG) { + Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.length); + } + + current++; + if (current < urls.length) { + // prepare next sub-mission + offsets[current] = offsets[current - 1] + length; + initializer(); + return; + } + } + + if (psAlgorithm != null && psState == 0) { + threads = new Thread[]{ + runAsync(1, this::doPostprocessing) + }; + return; + } + + + // this mission is fully finished + + unknownLength = false; + enqueued = false; + running = false; + + deleteThisFromFile(); + notify(DownloadManagerService.MESSAGE_FINISHED); + } + + private void notifyPostProcessing(int state) { + String action; + switch (state) { + case 1: + action = "Running"; + break; + case 2: + action = "Completed"; + break; + default: + action = "Failed"; + } + + Log.d(TAG, action + " postprocessing on " + storage.getName()); + + if (state == 2) { + psState = state; + return; + } + + synchronized (LOCK) { + // don't return without fully write the current state + psState = state; + writeThisToFile(); + } + } + + + /** + * Start downloading with multiple threads. + */ + public void start() { + if (running || isFinished() || urls.length < 1) return; + + // ensure that the previous state is completely paused. + joinForThreads(10000); + + running = true; + errCode = ERROR_NOTHING; + + if (hasInvalidStorage()) { + notifyError(ERROR_FILE_CREATION, null); + return; + } + + if (current >= urls.length) { + notifyFinished(); + return; + } + + notify(DownloadManagerService.MESSAGE_RUNNING); + + if (urls[current] == null) { + doRecover(ERROR_RESOURCE_GONE); + return; + } + + if (blocks == null) { + initializer(); + return; + } + + init = null; + finishCount = 0; + blockAcquired = new boolean[blocks.length]; + + if (blocks.length < 1) { + threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; + } else { + int remainingBlocks = 0; + for (int block : blocks) if (block >= 0) remainingBlocks++; + + if (remainingBlocks < 1) { + notifyFinished(); + return; + } + + threads = new Thread[Math.min(threadCount, remainingBlocks)]; + + for (int i = 0; i < threads.length; i++) { + threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); + } + } + } + + /** + * Pause the mission + */ + public void pause() { + if (!running) return; + + if (isPsRunning()) { + if (DEBUG) { + Log.w(TAG, "pause during post-processing is not applicable."); + } + return; + } + + running = false; + notify(DownloadManagerService.MESSAGE_PAUSED); + + if (init != null && init.isAlive()) { + // NOTE: if start() method is running ¡will no have effect! + init.interrupt(); + synchronized (LOCK) { + resetState(false, true, ERROR_NOTHING); + } + return; + } + + if (DEBUG && unknownLength) { + Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); + } + + init = null; + pauseThreads(); + } + + private void pauseThreads() { + running = false; + joinForThreads(-1); + writeThisToFile(); + } + + /** + * Removes the downloaded file and the meta file + */ + @Override + public boolean delete() { + if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); + + notify(DownloadManagerService.MESSAGE_DELETED); + + boolean res = deleteThisFromFile(); + + if (!super.delete()) return false; + return res; + } + + + /** + * Resets the mission state + * + * @param rollback {@code true} true to forget all progress, otherwise, {@code false} + * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} + */ + public void resetState(boolean rollback, boolean persistChanges, int errorCode) { + length = 0; + errCode = errorCode; + errObject = null; + unknownLength = false; + threads = new Thread[0]; + fallbackResumeOffset = 0; + blocks = null; + blockAcquired = null; + + if (rollback) current = 0; + if (persistChanges) writeThisToFile(); + } + + private void initializer() { + init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); + } + + private void writeThisToFileAsync() { + runAsync(-2, this::writeThisToFile); + } + + /** + * Write this {@link DownloadMission} to the meta file asynchronously + * if no thread is already running. + */ + void writeThisToFile() { + synchronized (LOCK) { + if (metadata == null) return; + Utility.writeToFile(metadata, this); + writingToFile = false; + } + } + + /** + * Indicates if the download if fully finished + * + * @return true, otherwise, false + */ + public boolean isFinished() { + return current >= urls.length && (psAlgorithm == null || psState == 2); + } + + /** + * Indicates if the download file is corrupt due a failed post-processing + * + * @return {@code true} if this mission is unrecoverable + */ + public boolean isPsFailed() { + switch (errCode) { + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_STOPPED: + return psAlgorithm.worksOnSameFile; + } + + return false; + } + + /** + * Indicates if a post-processing algorithm is running + * + * @return true, otherwise, false + */ + public boolean isPsRunning() { + return psAlgorithm != null && (psState == 1 || psState == 3); + } + + /** + * Indicated if the mission is ready + * + * @return true, otherwise, false + */ + public boolean isInitialized() { + return blocks != null; // DownloadMissionInitializer was executed + } + + /** + * Gets the approximated final length of the file + * + * @return the length in bytes + */ + public long getLength() { + long calculated; + if (psState == 1 || psState == 3) { + return length; + } + + calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; + calculated -= offsets[0];// don't count reserved space + + return calculated > nearLength ? calculated : nearLength; + } + + /** + * set this mission state on the queue + * + * @param queue true to add to the queue, otherwise, false + */ + public void setEnqueued(boolean queue) { + enqueued = queue; + writeThisToFileAsync(); + } + + /** + * Attempts to continue a blocked post-processing + * + * @param recover {@code true} to retry, otherwise, {@code false} to cancel + */ + public void psContinue(boolean recover) { + psState = 1; + errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; + threads[0].interrupt(); + } + + /** + * Indicates whatever the backed storage is invalid + * + * @return {@code true}, if storage is invalid and cannot be used + */ + public boolean hasInvalidStorage() { + return errCode == ERROR_PROGRESS_LOST || storage == null || storage.isInvalid() || !storage.existsAsFile(); + } + + /** + * Indicates whatever is possible to start the mission + * + * @return {@code true} is this mission its "healthy", otherwise, {@code false} + */ + public boolean isCorrupt() { + if (urls.length < 1) return false; + return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished(); + } + + /** + * Indicates if mission urls has expired and there an attempt to renovate them + * + * @return {@code true} if the mission is running a recovery procedure, otherwise, {@code false} + */ + public boolean isRecovering() { + return threads.length > 0 && threads[0] instanceof DownloadMissionRecover && threads[0].isAlive(); + } + + private void doPostprocessing() { + errCode = ERROR_NOTHING; + errObject = null; + Thread thread = Thread.currentThread(); + + notifyPostProcessing(1); + + if (DEBUG) { + thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage.getName()); + } + + Exception exception = null; + + try { + psAlgorithm.run(this); + } catch (Exception err) { + Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); + + if (err instanceof InterruptedIOException || err instanceof ClosedByInterruptException || thread.isInterrupted()) { + notifyError(DownloadMission.ERROR_POSTPROCESSING_STOPPED, null); + return; + } + + if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; + + exception = err; + } finally { + notifyPostProcessing(errCode == ERROR_NOTHING ? 2 : 0); + } + + if (errCode != ERROR_NOTHING) { + if (exception == null) exception = errObject; + notifyError(ERROR_POSTPROCESSING, exception); + return; + } + + notifyFinished(); + } + + /** + * Attempts to recover the download + * + * @param errorCode error code which trigger the recovery procedure + */ + void doRecover(int errorCode) { + Log.i(TAG, "Attempting to recover the mission: " + storage.getName()); + + if (recoveryInfo == null) { + notifyError(errorCode, null); + urls = new String[0];// mark this mission as dead + return; + } + + joinForThreads(0); + + threads = new Thread[]{ + runAsync(DownloadMissionRecover.mID, new DownloadMissionRecover(this, errorCode)) + }; + } + + private boolean deleteThisFromFile() { + synchronized (LOCK) { + boolean res = metadata.delete(); + metadata = null; + return res; + } + } + + /** + * 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 Thread runAsync(int id, Runnable who) { + return runAsync(id, new Thread(who)); + } + + /** + * run a new thread + * + * @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 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, storage.getName())); + } + + who.start(); + + return who; + } + + /** + * Waits at most {@code millis} milliseconds for the thread to die + * + * @param millis the time to wait in milliseconds + */ + private void joinForThreads(int millis) { + final Thread currentThread = Thread.currentThread(); + + if (init != null && init != currentThread && init.isAlive()) { + init.interrupt(); + + if (millis > 0) { + try { + init.join(millis); + } catch (InterruptedException e) { + Log.w(TAG, "Initializer thread is still running", e); + return; + } + } + } + + // if a thread is still alive, possible reasons: + // slow device + // the user is spamming start/pause buttons + // start() method called quickly after pause() + + for (Thread thread : threads) { + if (!thread.isAlive() || thread == Thread.currentThread()) continue; + thread.interrupt(); + } + + try { + for (Thread thread : threads) { + if (!thread.isAlive()) continue; + if (DEBUG) { + Log.w(TAG, "thread alive: " + thread.getName()); + } + if (millis > 0) thread.join(millis); + } + } catch (InterruptedException e) { + throw new RuntimeException("A download thread is still running", e); + } + } + + + static class HttpError extends Exception { + final int statusCode; + + HttpError(int statusCode) { + this.statusCode = statusCode; + } + + @Override + public String getMessage() { + return "HTTP " + statusCode; + } + } + + public static class Block { + public int position; + public int done; + } + + private static class Lock implements Serializable { + // java.lang.Object cannot be used because is not serializable + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java new file mode 100644 index 000000000..83389e489 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java @@ -0,0 +1,313 @@ +package us.shandian.giga.get; + +import android.util.Log; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamExtractor; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; +import java.util.List; + +import us.shandian.giga.get.DownloadMission.HttpError; + +import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; + +public class DownloadMissionRecover extends Thread { + private static final String TAG = "DownloadMissionRecover"; + static final int mID = -3; + + private final DownloadMission mMission; + private final boolean mNotInitialized; + + private final int mErrCode; + + private HttpURLConnection mConn; + private MissionRecoveryInfo mRecovery; + private StreamExtractor mExtractor; + + DownloadMissionRecover(DownloadMission mission, int errCode) { + mMission = mission; + mNotInitialized = mission.blocks == null && mission.current == 0; + mErrCode = errCode; + } + + @Override + public void run() { + if (mMission.source == null) { + mMission.notifyError(mErrCode, null); + return; + } + + Exception err = null; + int attempt = 0; + + while (attempt++ < mMission.maxRetry) { + try { + tryRecover(); + return; + } catch (InterruptedIOException | ClosedByInterruptException e) { + return; + } catch (Exception e) { + if (!mMission.running || super.isInterrupted()) return; + err = e; + } + } + + // give up + mMission.notifyError(mErrCode, err); + } + + private void tryRecover() throws ExtractionException, IOException, HttpError { + if (mExtractor == null) { + try { + StreamingService svr = NewPipe.getServiceByUrl(mMission.source); + mExtractor = svr.getStreamExtractor(mMission.source); + mExtractor.fetchPage(); + } catch (ExtractionException e) { + mExtractor = null; + throw e; + } + } + + // maybe the following check is redundant + if (!mMission.running || super.isInterrupted()) return; + + if (!mNotInitialized) { + // set the current download url to null in case if the recovery + // process is canceled. Next time start() method is called the + // recovery will be executed, saving time + mMission.urls[mMission.current] = null; + + mRecovery = mMission.recoveryInfo[mMission.current]; + resolveStream(); + return; + } + + Log.w(TAG, "mission is not fully initialized, this will take a while"); + + try { + for (; mMission.current < mMission.urls.length; mMission.current++) { + mRecovery = mMission.recoveryInfo[mMission.current]; + + if (test()) continue; + if (!mMission.running) return; + + resolveStream(); + if (!mMission.running) return; + + // before continue, check if the current stream was resolved + if (mMission.urls[mMission.current] == null) { + break; + } + } + } finally { + mMission.current = 0; + } + + mMission.writeThisToFile(); + + if (!mMission.running || super.isInterrupted()) return; + + mMission.running = false; + mMission.start(); + } + + private void resolveStream() throws IOException, ExtractionException, HttpError { + // FIXME: this getErrorMessage() always returns "video is unavailable" + /*if (mExtractor.getErrorMessage() != null) { + mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage())); + return; + }*/ + + String url = null; + + switch (mRecovery.kind) { + case 'a': + for (AudioStream audio : mExtractor.getAudioStreams()) { + if (audio.average_bitrate == mRecovery.desiredBitrate && audio.getFormat() == mRecovery.format) { + url = audio.getUrl(); + break; + } + } + break; + case 'v': + List videoStreams; + if (mRecovery.desired2) + videoStreams = mExtractor.getVideoOnlyStreams(); + else + videoStreams = mExtractor.getVideoStreams(); + for (VideoStream video : videoStreams) { + if (video.resolution.equals(mRecovery.desired) && video.getFormat() == mRecovery.format) { + url = video.getUrl(); + break; + } + } + break; + case 's': + for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.format)) { + String tag = subtitles.getLanguageTag(); + if (tag.equals(mRecovery.desired) && subtitles.isAutoGenerated() == mRecovery.desired2) { + url = subtitles.getURL(); + break; + } + } + break; + default: + throw new RuntimeException("Unknown stream type"); + } + + resolve(url); + } + + private void resolve(String url) throws IOException, HttpError { + if (mRecovery.validateCondition == null) { + Log.w(TAG, "validation condition not defined, the resource can be stale"); + } + + if (mMission.unknownLength || mRecovery.validateCondition == null) { + recover(url, false); + return; + } + + /////////////////////////////////////////////////////////////////////// + ////// Validate the http resource doing a range request + ///////////////////// + try { + mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length); + mConn.setRequestProperty("If-Range", mRecovery.validateCondition); + mMission.establishConnection(mID, mConn); + + int code = mConn.getResponseCode(); + + switch (code) { + case 200: + case 413: + // stale + recover(url, true); + return; + case 206: + // in case of validation using the Last-Modified date, check the resource length + long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range")); + boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length; + + recover(url, lengthMismatch); + return; + } + + throw new HttpError(code); + } finally { + disconnect(); + } + } + + private void recover(String url, boolean stale) { + Log.i(TAG, + String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url) + ); + + mMission.urls[mMission.current] = url; + + if (url == null) { + mMission.urls = new String[0]; + mMission.notifyError(ERROR_RESOURCE_GONE, null); + return; + } + + if (mNotInitialized) return; + + if (stale) { + mMission.resetState(false, false, DownloadMission.ERROR_NOTHING); + } + + mMission.writeThisToFile(); + + if (!mMission.running || super.isInterrupted()) return; + + mMission.running = false; + mMission.start(); + } + + private long[] parseContentRange(String value) { + long[] range = new long[3]; + + if (value == null) { + // this never should happen + return range; + } + + try { + value = value.trim(); + + if (!value.startsWith("bytes")) { + return range;// unknown range type + } + + int space = value.lastIndexOf(' ') + 1; + int dash = value.indexOf('-', space) + 1; + int bar = value.indexOf('/', dash); + + // start + range[0] = Long.parseLong(value.substring(space, dash - 1)); + + // end + range[1] = Long.parseLong(value.substring(dash, bar)); + + // resource length + value = value.substring(bar + 1); + if (value.equals("*")) { + range[2] = -1;// unknown length received from the server but should be valid + } else { + range[2] = Long.parseLong(value); + } + } catch (Exception e) { + // nothing to do + } + + return range; + } + + private boolean test() { + if (mMission.urls[mMission.current] == null) return false; + + try { + mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1); + mMission.establishConnection(mID, mConn); + + if (mConn.getResponseCode() == 200) return true; + } catch (Exception e) { + // nothing to do + } finally { + disconnect(); + } + + return false; + } + + private void disconnect() { + try { + try { + mConn.getInputStream().close(); + } finally { + mConn.disconnect(); + } + } catch (Exception e) { + // nothing to do + } finally { + mConn = null; + } + } + + @Override + public void interrupt() { + super.interrupt(); + if (mConn != null) disconnect(); + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java new file mode 100644 index 000000000..983cadf01 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -0,0 +1,184 @@ +package us.shandian.giga.get; + +import android.util.Log; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; + +import us.shandian.giga.get.DownloadMission.Block; +import us.shandian.giga.get.DownloadMission.HttpError; + +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; + + +/** + * Runnable to download blocks of a file until the file is completely downloaded, + * an error occurs or the process is stopped. + */ +public class DownloadRunnable extends Thread { + private static final String TAG = "DownloadRunnable"; + + private final DownloadMission mMission; + private final int mId; + + private HttpURLConnection mConn; + + DownloadRunnable(DownloadMission mission, int id) { + if (mission == null) throw new NullPointerException("mission is null"); + mMission = mission; + mId = id; + } + + private void releaseBlock(Block block, long remain) { + // set the block offset to -1 if it is completed + mMission.releaseBlock(block.position, remain < 0 ? -1 : block.done); + } + + @Override + public void run() { + boolean retry = false; + Block block = null; + int retryCount = 0; + SharpStream f; + + try { + f = mMission.storage.getStream(); + } catch (IOException e) { + mMission.notifyError(e);// this never should happen + return; + } + + while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING) { + if (!retry) { + block = mMission.acquireBlock(); + } + + if (block == null) { + if (DEBUG) Log.d(TAG, mId + ":no more blocks left, exiting"); + break; + } + + if (DEBUG) { + if (retry) + Log.d(TAG, mId + ":retry block at position=" + block.position + " from the start"); + else + Log.d(TAG, mId + ":acquired block at position=" + block.position + " done=" + block.done); + } + + long start = (long)block.position * DownloadMission.BLOCK_SIZE; + long end = start + DownloadMission.BLOCK_SIZE - 1; + + start += block.done; + + if (end >= mMission.length) { + end = mMission.length - 1; + } + + try { + mConn = mMission.openConnection(false, start, end); + mMission.establishConnection(mId, mConn); + + // check if the download can be resumed + if (mConn.getResponseCode() == 416) { + if (block.done > 0) { + // try again from the start (of the block) + mMission.notifyProgress(-block.done); + block.done = 0; + retry = true; + mConn.disconnect(); + continue; + } + + throw new DownloadMission.HttpError(416); + } + + retry = false; + + // The server may be ignoring the range request + if (mConn.getResponseCode() != 206) { + if (DEBUG) { + Log.e(TAG, mId + ":Unsupported " + mConn.getResponseCode()); + } + mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); + break; + } + + f.seek(mMission.offsets[mMission.current] + start); + + try (InputStream is = mConn.getInputStream()) { + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; + int len; + + // use always start <= end + // fixes a deadlock because in some videos, youtube is sending one byte alone + while (start <= end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { + f.write(buf, 0, len); + start += len; + block.done += len; + mMission.notifyProgress(len); + } + } + + if (DEBUG && mMission.running) { + Log.d(TAG, mId + ":position " + block.position + " stopped " + start + "/" + end); + } + } catch (Exception e) { + if (!mMission.running || e instanceof ClosedByInterruptException) break; + + if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired, recover + f.close(); + + if (mId == 1) { + // only the first thread will execute the recovery procedure + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + } + return; + } + + if (retryCount++ >= mMission.maxRetry) { + mMission.notifyError(e); + break; + } + + retry = true; + } finally { + if (!retry) releaseBlock(block, end - start); + } + } + + f.close(); + + if (DEBUG) { + Log.d(TAG, "thread " + mId + " exited from main download loop"); + } + + if (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running) { + if (DEBUG) { + Log.d(TAG, "no error has happened, notifying"); + } + mMission.notifyFinished(); + } + + if (DEBUG && !mMission.running) { + Log.d(TAG, "The mission has been paused. Passing."); + } + } + + @Override + public void interrupt() { + super.interrupt(); + + try { + if (mConn != null) mConn.disconnect(); + } catch (Exception e) { + // nothing to do + } + } + +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java new file mode 100644 index 000000000..b30ede2d4 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -0,0 +1,155 @@ +package us.shandian.giga.get; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; + +import us.shandian.giga.get.DownloadMission.HttpError; +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; + +/** + * Single-threaded fallback mode + */ +public class DownloadRunnableFallback extends Thread { + private static final String TAG = "DownloadRunnableFallbac"; + + private final DownloadMission mMission; + + private int mRetryCount = 0; + private InputStream mIs; + private SharpStream mF; + private HttpURLConnection mConn; + + DownloadRunnableFallback(@NonNull DownloadMission mission) { + mMission = mission; + } + + private void dispose() { + try { + try { + if (mIs != null) mIs.close(); + } finally { + mConn.disconnect(); + } + } catch (IOException e) { + // nothing to do + } + + if (mF != null) mF.close(); + } + + @Override + public void run() { + boolean done; + long start = mMission.fallbackResumeOffset; + + if (DEBUG && !mMission.unknownLength && start > 0) { + Log.i(TAG, "Resuming a single-thread download at " + start); + } + + try { + long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; + + int mId = 1; + mConn = mMission.openConnection(false, rangeStart, -1); + + if (mRetryCount == 0 && rangeStart == -1) { + // workaround: bypass android connection pool + mConn.setRequestProperty("Range", "bytes=0-"); + } + + mMission.establishConnection(mId, mConn); + + // check if the download can be resumed + if (mConn.getResponseCode() == 416 && start > 0) { + mMission.notifyProgress(-start); + start = 0; + mRetryCount--; + throw new DownloadMission.HttpError(416); + } + + // secondary check for the file length + if (!mMission.unknownLength) + mMission.unknownLength = Utility.getContentLength(mConn) == -1; + + if (mMission.unknownLength || mConn.getResponseCode() == 200) { + // restart amount of bytes downloaded + mMission.done = mMission.offsets[mMission.current] - mMission.offsets[0]; + } + + mF = mMission.storage.getStream(); + mF.seek(mMission.offsets[mMission.current] + start); + + mIs = mConn.getInputStream(); + + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; + int len = 0; + + while (mMission.running && (len = mIs.read(buf, 0, buf.length)) != -1) { + mF.write(buf, 0, len); + start += len; + mMission.notifyProgress(len); + } + + dispose(); + + // if thread goes interrupted check if the last part is written. This avoid re-download the whole file + done = len == -1; + } catch (Exception e) { + dispose(); + + mMission.fallbackResumeOffset = start; + + if (!mMission.running || e instanceof ClosedByInterruptException) return; + + if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired, recover + dispose(); + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + return; + } + + if (mRetryCount++ >= mMission.maxRetry) { + mMission.notifyError(e); + return; + } + + if (DEBUG) { + Log.e(TAG, "got exception, retrying...", e); + } + + run();// try again + return; + } + + if (done) { + mMission.notifyFinished(); + } else { + mMission.fallbackResumeOffset = 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..29f3c6296 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -0,0 +1,18 @@ +package us.shandian.giga.get; + +import androidx.annotation.NonNull; + +public class FinishedMission extends Mission { + + public FinishedMission() { + } + + public FinishedMission(@NonNull DownloadMission mission) { + source = mission.source; + length = mission.length; + timestamp = mission.timestamp; + kind = mission.kind; + storage = mission.storage; + } + +} 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..81cddb4ae --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -0,0 +1,60 @@ +package us.shandian.giga.get; + +import androidx.annotation.NonNull; + +import java.io.Serializable; +import java.util.Calendar; + +import us.shandian.giga.io.StoredFileHelper; + +public abstract class Mission implements Serializable { + private static final long serialVersionUID = 1L;// last bump: 27 march 2019 + + /** + * 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; + + /** + * pre-defined content type + */ + public char kind; + + /** + * The downloaded file + */ + public StoredFileHelper storage; + + /** + * Delete the downloaded file + * + * @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false} + */ + public boolean delete() { + if (storage != null) return storage.delete(); + return true; + } + + /** + * Indicate if this mission is deleted whatever is stored + */ + public transient boolean deleted = false; + + @NonNull + @Override + public String toString() { + Calendar calendar = Calendar.getInstance(); + calendar.setTimeInMillis(timestamp); + return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); + } +} diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java new file mode 100644 index 000000000..4a2948131 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java @@ -0,0 +1,115 @@ +package us.shandian.giga.get; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.io.Serializable; + +public class MissionRecoveryInfo implements Serializable, Parcelable { + private static final long serialVersionUID = 0L; + + MediaFormat format; + String desired; + boolean desired2; + int desiredBitrate; + byte kind; + String validateCondition = null; + + public MissionRecoveryInfo(@NonNull Stream stream) { + if (stream instanceof AudioStream) { + desiredBitrate = ((AudioStream) stream).average_bitrate; + desired2 = false; + kind = 'a'; + } else if (stream instanceof VideoStream) { + desired = ((VideoStream) stream).getResolution(); + desired2 = ((VideoStream) stream).isVideoOnly(); + kind = 'v'; + } else if (stream instanceof SubtitlesStream) { + desired = ((SubtitlesStream) stream).getLanguageTag(); + desired2 = ((SubtitlesStream) stream).isAutoGenerated(); + kind = 's'; + } else { + throw new RuntimeException("Unknown stream kind"); + } + + format = stream.getFormat(); + if (format == null) throw new NullPointerException("Stream format cannot be null"); + } + + @NonNull + @Override + public String toString() { + String info; + StringBuilder str = new StringBuilder(); + str.append("{type="); + switch (kind) { + case 'a': + str.append("audio"); + info = "bitrate=" + desiredBitrate; + break; + case 'v': + str.append("video"); + info = "quality=" + desired + " videoOnly=" + desired2; + break; + case 's': + str.append("subtitles"); + info = "language=" + desired + " autoGenerated=" + desired2; + break; + default: + info = ""; + str.append("other"); + } + + str.append(" format=") + .append(format.getName()) + .append(' ') + .append(info) + .append('}'); + + return str.toString(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeInt(this.format.ordinal()); + parcel.writeString(this.desired); + parcel.writeInt(this.desired2 ? 0x01 : 0x00); + parcel.writeInt(this.desiredBitrate); + parcel.writeByte(this.kind); + parcel.writeString(this.validateCondition); + } + + private MissionRecoveryInfo(Parcel parcel) { + this.format = MediaFormat.values()[parcel.readInt()]; + this.desired = parcel.readString(); + this.desired2 = parcel.readInt() != 0x00; + this.desiredBitrate = parcel.readInt(); + this.kind = parcel.readByte(); + this.validateCondition = parcel.readString(); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public MissionRecoveryInfo createFromParcel(Parcel source) { + return new MissionRecoveryInfo(source); + } + + @Override + public MissionRecoveryInfo[] newArray(int size) { + return new MissionRecoveryInfo[size]; + } + }; +} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java new file mode 100644 index 000000000..bf9460b3d --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -0,0 +1,238 @@ +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.database.sqlite.SQLiteOpenHelper; +import android.net.Uri; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.io.StoredFileHelper; + +/** + * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s + */ +public class FinishedMissionStore extends SQLiteOpenHelper { + + // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) + private static final String DATABASE_NAME = "downloads.db"; + + private static final int DATABASE_VERSION = 4; + + /** + * The table name of download missions (old) + */ + private static final String MISSIONS_TABLE_NAME_v2 = "download_missions"; + + /** + * The table name of download missions + */ + private static final String FINISHED_TABLE_NAME = "finished_missions"; + + /** + * The key to the urls of a mission + */ + private static final String KEY_SOURCE = "url"; + + + /** + * The key to the done. + */ + private static final String KEY_DONE = "bytes_downloaded"; + + private static final String KEY_TIMESTAMP = "timestamp"; + + private static final String KEY_KIND = "kind"; + + private static final String KEY_PATH = "path"; + + /** + * The statement to create the table + */ + private static final String MISSIONS_CREATE_TABLE = + "CREATE TABLE " + FINISHED_TABLE_NAME + " (" + + KEY_PATH + " TEXT NOT NULL, " + + KEY_SOURCE + " TEXT NOT NULL, " + + KEY_DONE + " INTEGER NOT NULL, " + + KEY_TIMESTAMP + " INTEGER NOT NULL, " + + KEY_KIND + " TEXT NOT NULL, " + + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; + + + private Context context; + + public FinishedMissionStore(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + this.context = context; + } + + @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_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;"); + oldVersion++; + } + + if (oldVersion == 3) { + final String KEY_LOCATION = "location"; + final String KEY_NAME = "name"; + + db.execSQL(MISSIONS_CREATE_TABLE); + + Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, + null, null, null, KEY_TIMESTAMP); + + int count = cursor.getCount(); + if (count > 0) { + db.beginTransaction(); + while (cursor.moveToNext()) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, cursor.getString(cursor.getColumnIndex(KEY_SOURCE))); + values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE))); + values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP))); + values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND))); + values.put(KEY_PATH, Uri.fromFile( + new File( + cursor.getString(cursor.getColumnIndex(KEY_LOCATION)), + cursor.getString(cursor.getColumnIndex(KEY_NAME)) + ) + ).toString()); + + db.insert(FINISHED_TABLE_NAME, null, values); + } + db.setTransactionSuccessful(); + db.endTransaction(); + } + + cursor.close(); + db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); + } + } + + /** + * Returns all values of the download mission as ContentValues. + * + * @param downloadMission the download mission + * @return the content values + */ + private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { + ContentValues values = new ContentValues(); + values.put(KEY_SOURCE, downloadMission.source); + values.put(KEY_PATH, downloadMission.storage.getUri().toString()); + values.put(KEY_DONE, downloadMission.length); + values.put(KEY_TIMESTAMP, downloadMission.timestamp); + values.put(KEY_KIND, String.valueOf(downloadMission.kind)); + return values; + } + + private FinishedMission getMissionFromCursor(Cursor cursor) { + if (cursor == null) throw new NullPointerException("cursor is null"); + + String kind = cursor.getString(cursor.getColumnIndex(KEY_KIND)); + if (kind == null || kind.isEmpty()) kind = "?"; + + String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)); + + FinishedMission mission = new FinishedMission(); + + mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)); + mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); + mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); + mission.kind = kind.charAt(0); + + try { + mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); + } catch (Exception e) { + Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e); + mission.storage = new StoredFileHelper(null, path, "", ""); + } + + return mission; + } + + + ////////////////////////////////// + // Data source methods + /////////////////////////////////// + + public ArrayList loadFinishedMissions() { + SQLiteDatabase database = getReadableDatabase(); + Cursor cursor = database.query(FINISHED_TABLE_NAME, null, null, + null, null, null, KEY_TIMESTAMP + " DESC"); + + int count = cursor.getCount(); + if (count == 0) return new ArrayList<>(1); + + ArrayList result = new ArrayList<>(count); + while (cursor.moveToNext()) { + result.add(getMissionFromCursor(cursor)); + } + + return result; + } + + public void addFinishedMission(DownloadMission downloadMission) { + if (downloadMission == null) throw new NullPointerException("downloadMission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(downloadMission); + database.insert(FINISHED_TABLE_NAME, null, values); + } + + public void deleteMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + String ts = String.valueOf(mission.timestamp); + + SQLiteDatabase database = getWritableDatabase(); + + if (mission instanceof FinishedMission) { + if (mission.storage.isInvalid()) { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); + } else { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ + ts, mission.storage.getUri().toString() + }); + } + } else { + throw new UnsupportedOperationException("DownloadMission"); + } + } + + public void updateMission(Mission mission) { + if (mission == null) throw new NullPointerException("mission is null"); + SQLiteDatabase database = getWritableDatabase(); + ContentValues values = getValuesOfMission(mission); + String ts = String.valueOf(mission.timestamp); + + int rowsAffected; + + if (mission instanceof FinishedMission) { + if (mission.storage.isInvalid()) { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts}); + } else { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ + mission.storage.getUri().toString() + }); + } + } else { + throw new UnsupportedOperationException("DownloadMission"); + } + + if (rowsAffected != 1) { + Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java new file mode 100644 index 000000000..6fa9b7ddd --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java @@ -0,0 +1,155 @@ +package us.shandian.giga.io; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; + +public class ChunkFileInputStream extends SharpStream { + private static final int REPORT_INTERVAL = 256 * 1024; + + private SharpStream source; + private final long offset; + private final long length; + private long position; + + private long progressReport; + private final ProgressReport onProgress; + + public ChunkFileInputStream(SharpStream target, long start, long end, ProgressReport callback) throws IOException { + source = target; + offset = start; + length = end - start; + position = 0; + onProgress = callback; + progressReport = REPORT_INTERVAL; + + if (length < 1) { + source.close(); + 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; + + if (onProgress != null && position > progressReport) { + onProgress.report(position); + progressReport = position + REPORT_INTERVAL; + } + + 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 long available() { + return length - position; + } + + @SuppressWarnings("EmptyCatchBlock") + @Override + public void close() { + source.close(); + source = null; + } + + @Override + public boolean isClosed() { + 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) { + } + +} diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java new file mode 100644 index 000000000..c75b1c00e --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java @@ -0,0 +1,487 @@ +package us.shandian.giga.io; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +public class CircularFileWriter extends SharpStream { + + private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB + private final static int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB + private final static int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB + private final static int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB + + private OffsetChecker callback; + + public ProgressReport onProgress; + public WriteErrorHandle onWriteError; + + private long reportPosition; + private long maxLengthKnown = -1; + + private BufferedFile out; + private BufferedFile aux; + + public CircularFileWriter(SharpStream target, File temp, OffsetChecker checker) throws IOException { + if (checker == null) { + throw new NullPointerException("checker is null"); + } + + if (!temp.exists()) { + if (!temp.createNewFile()) { + throw new IOException("Cannot create a temporal file"); + } + } + + aux = new BufferedFile(temp); + out = new BufferedFile(target); + + callback = checker; + + reportPosition = NOTIFY_BYTES_INTERVAL; + } + + private void flushAuxiliar(long amount) throws IOException { + if (aux.length < 1) { + return; + } + + out.flush(); + aux.flush(); + + boolean underflow = aux.offset < aux.length || out.offset < out.length; + byte[] buffer = new byte[COPY_BUFFER_SIZE]; + + aux.target.seek(0); + out.target.seek(out.length); + + long length = amount; + while (length > 0) { + int read = (int) Math.min(length, Integer.MAX_VALUE); + read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); + + if (read < 1) { + amount -= length; + break; + } + + out.writeProof(buffer, read); + length -= read; + } + + if (underflow) { + if (out.offset >= out.length) { + // calculate the aux underflow pointer + if (aux.offset < amount) { + out.offset += aux.offset; + aux.offset = 0; + out.target.seek(out.offset); + } else { + aux.offset -= amount; + out.offset = out.length + amount; + } + } else { + aux.offset = 0; + } + } else { + out.offset += amount; + aux.offset -= amount; + } + + out.length += amount; + + if (out.length > maxLengthKnown) { + maxLengthKnown = out.length; + } + + if (amount < aux.length) { + // move the excess data to the beginning of the file + long readOffset = amount; + long writeOffset = 0; + + aux.length -= amount; + length = aux.length; + while (length > 0) { + int read = (int) Math.min(length, Integer.MAX_VALUE); + read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); + + aux.target.seek(writeOffset); + aux.writeProof(buffer, read); + + writeOffset += read; + readOffset += read; + length -= read; + + aux.target.seek(readOffset); + } + + aux.target.setLength(aux.length); + return; + } + + if (aux.length > THRESHOLD_AUX_LENGTH) { + aux.target.setLength(THRESHOLD_AUX_LENGTH);// or setLength(0); + } + + aux.reset(); + } + + /** + * 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 { + flushAuxiliar(aux.length); + + out.flush(); + + // change file length (if required) + long length = Math.max(maxLengthKnown, out.length); + if (length != out.target.length()) { + out.target.setLength(length); + } + + close(); + + return length; + } + + /** + * Close the file without flushing any buffer + */ + @Override + public void close() { + if (out != null) { + out.close(); + out = null; + } + if (aux != null) { + aux.close(); + aux = null; + } + } + + @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 available; + long offsetOut = out.getOffset(); + long offsetAux = aux.getOffset(); + long end = callback.check(); + + if (end == -1) { + available = Integer.MAX_VALUE; + } else if (end < offsetOut) { + throw new IOException("The reported offset is invalid: " + end + "<" + offsetOut); + } else { + available = end - offsetOut; + } + + boolean usingAux = aux.length > 0 && offsetOut >= out.length; + boolean underflow = offsetAux < aux.length || offsetOut < out.length; + + if (usingAux) { + // before continue calculate the final length of aux + long length = offsetAux + len; + if (underflow) { + if (aux.length > length) { + length = aux.length;// the length is not changed + } + } else { + length = aux.length + len; + } + + aux.write(b, off, len); + + if (length >= THRESHOLD_AUX_LENGTH && length <= available) { + flushAuxiliar(available); + } + } else { + if (underflow) { + available = out.length - offsetOut; + } + + int length = Math.min(len, (int) Math.min(Integer.MAX_VALUE, available)); + out.write(b, off, length); + + len -= length; + off += length; + + if (len > 0) { + aux.write(b, off, len); + } + } + + if (onProgress != null) { + long absoluteOffset = out.getOffset() + aux.getOffset(); + if (absoluteOffset > reportPosition) { + reportPosition = absoluteOffset + NOTIFY_BYTES_INTERVAL; + onProgress.report(absoluteOffset); + } + } + } + + @Override + public void flush() throws IOException { + aux.flush(); + out.flush(); + + long total = out.length + aux.length; + if (total > maxLengthKnown) { + maxLengthKnown = total;// save the current file length in case the method {@code rewind()} is called + } + } + + @Override + public long skip(long amount) throws IOException { + seek(out.getOffset() + aux.getOffset() + amount); + return amount; + } + + @Override + public void rewind() throws IOException { + if (onProgress != null) { + onProgress.report(0);// rollback the whole progress + } + + seek(0); + + reportPosition = NOTIFY_BYTES_INTERVAL; + } + + @Override + public void seek(long offset) throws IOException { + long total = out.length + aux.length; + + if (offset == total) { + // do not ignore the seek offset if a underflow exists + long relativeOffset = out.getOffset() + aux.getOffset(); + if (relativeOffset == total) { + return; + } + } + + // flush everything, avoid any underflow + flush(); + + if (offset < 0 || offset > total) { + throw new IOException("desired offset is outside of range=0-" + total + " offset=" + offset); + } + + if (offset > out.length) { + out.seek(out.length); + aux.seek(offset - out.length); + } else { + out.seek(offset); + aux.seek(0); + } + } + + @Override + public boolean isClosed() { + return out == null; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + @Override + public boolean canSeek() { + 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 long 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 WriteErrorHandle { + + /** + * Attempts to handle a I/O exception + * + * @param err the cause + * @return {@code true} to retry and continue, otherwise, {@code false} + * and throw the exception + */ + boolean handle(Exception err); + } + + class BufferedFile { + + final SharpStream target; + + private long offset; + long length; + + private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; + private int queueSize; + + BufferedFile(File file) throws FileNotFoundException { + this.target = new FileStream(file); + } + + BufferedFile(SharpStream target) { + this.target = target; + } + + long getOffset() { + return offset + queueSize;// absolute offset in the file + } + + void close() { + queue = null; + target.close(); + } + + void write(byte[] b, int off, int len) throws IOException { + while (len > 0) { + // if the queue is full, the method available() will flush the queue + int read = Math.min(available(), len); + + // enqueue incoming buffer + System.arraycopy(b, off, queue, queueSize, read); + queueSize += read; + + len -= read; + off += read; + } + + long total = offset + queueSize; + if (total > length) { + length = total;// save length + } + } + + void flush() throws IOException { + writeProof(queue, queueSize); + offset += queueSize; + queueSize = 0; + } + + protected void rewind() throws IOException { + offset = 0; + target.seek(0); + } + + int available() throws IOException { + if (queueSize >= queue.length) { + flush(); + return queue.length; + } + + return queue.length - queueSize; + } + + void reset() throws IOException { + offset = 0; + length = 0; + target.seek(0); + } + + void seek(long absoluteOffset) throws IOException { + if (absoluteOffset == offset) { + return;// nothing to do + } + offset = absoluteOffset; + target.seek(absoluteOffset); + } + + void writeProof(byte[] buffer, int length) throws IOException { + if (onWriteError == null) { + target.write(buffer, 0, length); + return; + } + + while (true) { + try { + target.write(buffer, 0, length); + return; + } catch (Exception e) { + if (!onWriteError.handle(e)) { + throw e;// give up + } + } + } + } + + @NonNull + @Override + public String toString() { + String absLength; + + try { + absLength = Long.toString(target.length()); + } catch (IOException e) { + absLength = "[" + e.getLocalizedMessage() + "]"; + } + + return String.format( + "offset=%s length=%s queue=%s absLength=%s", + offset, length, queueSize, absLength + ); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStream.java b/app/src/main/java/us/shandian/giga/io/FileStream.java new file mode 100644 index 000000000..ea9360e7f --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStream.java @@ -0,0 +1,131 @@ +package us.shandian.giga.io; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.RandomAccessFile; + +/** + * @author kapodamy + */ +public class FileStream extends SharpStream { + + public RandomAccessFile source; + + public FileStream(@NonNull File target) throws FileNotFoundException { + this.source = new RandomAccessFile(target, "rw"); + } + + public FileStream(@NonNull String path) throws FileNotFoundException { + this.source = new RandomAccessFile(path, "rw"); + } + + @Override + public int read() throws IOException { + return source.read(); + } + + @Override + public int read(byte b[]) throws IOException { + return source.read(b); + } + + @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 { + return source.skipBytes((int) pos); + } + + @Override + public long available() { + try { + return source.length() - source.getFilePointer(); + } catch (IOException e) { + return 0; + } + } + + @Override + public void close() { + if (source == null) return; + try { + source.close(); + } catch (IOException err) { + // nothing to do + } + source = null; + } + + @Override + public boolean isClosed() { + return source == null; + } + + @Override + public void rewind() throws IOException { + source.seek(0); + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + @Override + public boolean canSeek() { + return true; + } + + @Override + public boolean canSetLength() { + return true; + } + + @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 setLength(long length) throws IOException { + source.setLength(length); + } + + @Override + public void seek(long offset) throws IOException { + source.seek(offset); + } + + @Override + public long length() throws IOException { + return source.length(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java new file mode 100644 index 000000000..7e3c0e852 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java @@ -0,0 +1,146 @@ +package us.shandian.giga.io; + +import android.content.ContentResolver; +import android.net.Uri; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.channels.FileChannel; + +public class FileStreamSAF extends SharpStream { + + private final FileInputStream in; + private final FileOutputStream out; + private final FileChannel channel; + private final ParcelFileDescriptor file; + + private boolean disposed; + + public FileStreamSAF(@NonNull ContentResolver contentResolver, Uri fileUri) throws IOException { + // Notes: + // the file must exists first + // ¡read-write mode must allow seek! + // It is not guaranteed to work with files in the cloud (virtual files), tested in local storage devices + + file = contentResolver.openFileDescriptor(fileUri, "rw"); + + if (file == null) { + throw new IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()); + } + + in = new FileInputStream(file.getFileDescriptor()); + out = new FileOutputStream(file.getFileDescriptor()); + channel = out.getChannel();// or use in.getChannel() + } + + @Override + public int read() throws IOException { + return in.read(); + } + + @Override + public int read(byte[] buffer) throws IOException { + return in.read(buffer); + } + + @Override + public int read(byte[] buffer, int offset, int count) throws IOException { + return in.read(buffer, offset, count); + } + + @Override + public long skip(long amount) throws IOException { + return in.skip(amount);// ¿or use channel.position(channel.position() + amount)? + } + + @Override + public long available() { + try { + return in.available(); + } catch (IOException e) { + return 0;// ¡but not -1! + } + } + + @Override + public void rewind() throws IOException { + seek(0); + } + + @Override + public void close() { + try { + disposed = true; + + file.close(); + in.close(); + out.close(); + channel.close(); + } catch (IOException e) { + Log.e("FileStreamSAF", "close() error", e); + } + } + + @Override + public boolean isClosed() { + return disposed; + } + + @Override + public boolean canRewind() { + return true; + } + + @Override + public boolean canRead() { + return true; + } + + @Override + public boolean canWrite() { + return true; + } + + public boolean canSetLength() { + return true; + } + + public boolean canSeek() { + return true; + } + + @Override + public void write(byte value) throws IOException { + out.write(value); + } + + @Override + public void write(byte[] buffer) throws IOException { + out.write(buffer); + } + + @Override + public void write(byte[] buffer, int offset, int count) throws IOException { + out.write(buffer, offset, count); + } + + public void setLength(long length) throws IOException { + channel.truncate(length); + } + + public void seek(long offset) throws IOException { + channel.position(offset); + } + + @Override + public long length() throws IOException { + return channel.size(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/ProgressReport.java b/app/src/main/java/us/shandian/giga/io/ProgressReport.java new file mode 100644 index 000000000..e382747f6 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/ProgressReport.java @@ -0,0 +1,11 @@ +package us.shandian.giga.io; + +public interface ProgressReport { + + /** + * Report the size of the new file + * + * @param progress the new size + */ + void report(long progress); +} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java new file mode 100644 index 000000000..c95f577a8 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java @@ -0,0 +1,61 @@ +/* + * 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.io; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.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() { + long res = base.available(); + return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; + } + + @Override + public void close() { + base.close(); + } +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java new file mode 100644 index 000000000..630e3263c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java @@ -0,0 +1,292 @@ +package us.shandian.giga.io; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.DocumentsContract; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; + +import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; +import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; + + +public class StoredDirectoryHelper { + public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + + private File ioTree; + private DocumentFile docTree; + + private Context context; + + private String tag; + + @SuppressLint("NewApi") + public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException { + this.tag = tag; + + if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { + this.ioTree = new File(URI.create(path.toString())); + return; + } + + this.context = context; + + try { + this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); + } catch (Exception e) { + throw new IOException(e); + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + throw new IOException("Storage Access Framework with Directory API is not available"); + + this.docTree = DocumentFile.fromTreeUri(context, path); + + if (this.docTree == null) + throw new IOException("Failed to create the tree from Uri"); + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredDirectoryHelper(@NonNull URI location, String tag) { + ioTree = new File(location); + this.tag = tag; + } + + public StoredFileHelper createFile(String filename, String mime) { + return createFile(filename, mime, false); + } + + public StoredFileHelper createUniqueFile(String name, String mime) { + ArrayList matches = new ArrayList<>(); + String[] filename = splitFilename(name); + String lcFilename = filename[0].toLowerCase(); + + if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + for (File file : ioTree.listFiles()) + addIfStartWith(matches, lcFilename, file.getName()); + } else { + // warning: SAF file listing is very slow + Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()) + ); + + String[] projection = new String[]{COLUMN_DISPLAY_NAME}; + String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; + ContentResolver cr = context.getContentResolver(); + + try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFilename}, null)) { + if (cursor != null) { + while (cursor.moveToNext()) + addIfStartWith(matches, lcFilename, cursor.getString(0)); + } + } + } + + if (matches.size() < 1) { + return createFile(name, mime, true); + } else { + // check if the filename is in use + String lcName = name.toLowerCase(); + for (String testName : matches) { + if (testName.equals(lcName)) { + lcName = null; + break; + } + } + + // check if not in use + if (lcName != null) return createFile(name, mime, true); + } + + Collections.sort(matches, String::compareTo); + + for (int i = 1; i < 1000; i++) { + if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) + return createFile(makeFileName(filename[0], i, filename[1]), mime, true); + } + + return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false); + } + + private StoredFileHelper createFile(String filename, String mime, boolean safe) { + StoredFileHelper storage; + + try { + if (docTree == null) + storage = new StoredFileHelper(ioTree, filename, mime); + else + storage = new StoredFileHelper(context, docTree, filename, mime, safe); + } catch (IOException e) { + return null; + } + + storage.tag = tag; + + return storage; + } + + public Uri getUri() { + return docTree == null ? Uri.fromFile(ioTree) : docTree.getUri(); + } + + public boolean exists() { + return docTree == null ? ioTree.exists() : docTree.exists(); + } + + /** + * Indicates whatever if is possible access using the {@code java.io} API + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + return docTree == null; + } + + /** + * Only using Java I/O. Creates the directory named by this abstract pathname, including any + * necessary but nonexistent parent directories. Note that if this + * operation fails it may have succeeded in creating some of the necessary + * parent directories. + * + * @return true if and only if the directory was created, + * along with all necessary parent directories or already exists; false + * otherwise + */ + public boolean mkdirs() { + if (docTree == null) { + return ioTree.exists() || ioTree.mkdirs(); + } + + if (docTree.exists()) return true; + + try { + DocumentFile parent; + String child = docTree.getName(); + + while (true) { + parent = docTree.getParentFile(); + if (parent == null || child == null) break; + if (parent.exists()) return true; + + parent.createDirectory(child); + + child = parent.getName();// for the next iteration + } + } catch (Exception e) { + // no more parent directories or unsupported by the storage provider + } + + return false; + } + + public String getTag() { + return tag; + } + + public Uri findFile(String filename) { + if (docTree == null) { + File res = new File(ioTree, filename); + return res.exists() ? Uri.fromFile(res) : null; + } + + DocumentFile res = findFileSAFHelper(context, docTree, filename); + return res == null ? null : res.getUri(); + } + + public boolean canWrite() { + return docTree == null ? ioTree.canWrite() : docTree.canWrite(); + } + + @NonNull + @Override + public String toString() { + return docTree == null ? Uri.fromFile(ioTree).toString() : docTree.getUri().toString(); + } + + + //////////////////// + // Utils + /////////////////// + + private static void addIfStartWith(ArrayList list, @NonNull String base, String str) { + if (str == null || str.isEmpty()) return; + str = str.toLowerCase(); + if (str.startsWith(base)) list.add(str); + } + + private static String[] splitFilename(@NonNull String filename) { + int dotIndex = filename.lastIndexOf('.'); + + if (dotIndex < 0 || (dotIndex == filename.length() - 1)) + return new String[]{filename, ""}; + + return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; + } + + private static String makeFileName(String name, int idx, String ext) { + return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext); + } + + /** + * Fast (but not enough) file/directory finder under the storage access framework + * + * @param context The context + * @param tree Directory where search + * @param filename Target filename + * @return A {@link DocumentFile} contain the reference, otherwise, null + */ + static DocumentFile findFileSAFHelper(@Nullable Context context, DocumentFile tree, String filename) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return tree.findFile(filename);// warning: this is very slow + } + + if (!tree.canRead()) return null;// missing read permission + + final int name = 0; + final int documentId = 1; + + // LOWER() SQL function is not supported + String selection = COLUMN_DISPLAY_NAME + " = ?"; + //String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + + Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( + tree.getUri(), DocumentsContract.getDocumentId(tree.getUri()) + ); + String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; + ContentResolver contentResolver = context.getContentResolver(); + + filename = filename.toLowerCase(); + + try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) { + if (cursor == null) return null; + + while (cursor.moveToNext()) { + if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename)) + continue; + + return DocumentFile.fromSingleUri( + context, DocumentsContract.buildDocumentUriUsingTree( + tree.getUri(), cursor.getString(documentId) + ) + ); + } + } + + return null; + } + +} diff --git a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java new file mode 100644 index 000000000..5a349c7e8 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java @@ -0,0 +1,389 @@ +package us.shandian.giga.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; +import androidx.fragment.app.Fragment; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; + +import static us.shandian.giga.util.Utility.getDocumentId; +import static us.shandian.giga.util.Utility.equalsIgnoreCase; + +public class StoredFileHelper implements Serializable { + private static final long serialVersionUID = 0L; + public static final String DEFAULT_MIME = "application/octet-stream"; + + private transient DocumentFile docFile; + private transient DocumentFile docTree; + private transient File ioFile; + private transient Context context; + + protected String source; + private String sourceTree; + + protected String tag; + + private String srcName; + private String srcType; + + public StoredFileHelper(@Nullable Uri parent, String filename, String mime, String tag) { + this.source = null;// this instance will be "invalid" see invalidate()/isInvalid() methods + + this.srcName = filename; + this.srcType = mime == null ? DEFAULT_MIME : mime; + if (parent != null) this.sourceTree = parent.toString(); + + this.tag = tag; + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + StoredFileHelper(@Nullable Context context, DocumentFile tree, String filename, String mime, boolean safe) throws IOException { + this.docTree = tree; + this.context = context; + + DocumentFile res; + + if (safe) { + // no conflicts (the filename is not in use) + res = this.docTree.createFile(mime, filename); + if (res == null) throw new IOException("Cannot create the file"); + } else { + res = createSAF(context, mime, filename); + } + + this.docFile = res; + + this.source = docFile.getUri().toString(); + this.sourceTree = docTree.getUri().toString(); + + this.srcName = this.docFile.getName(); + this.srcType = this.docFile.getType(); + } + + StoredFileHelper(File location, String filename, String mime) throws IOException { + this.ioFile = new File(location, filename); + + if (this.ioFile.exists()) { + if (!this.ioFile.isFile() && !this.ioFile.delete()) + throw new IOException("The filename is already in use by non-file entity and cannot overwrite it"); + } else { + if (!this.ioFile.createNewFile()) + throw new IOException("Cannot create the file"); + } + + this.source = Uri.fromFile(this.ioFile).toString(); + this.sourceTree = Uri.fromFile(location).toString(); + + this.srcName = ioFile.getName(); + this.srcType = mime; + } + + @TargetApi(Build.VERSION_CODES.KITKAT) + public StoredFileHelper(Context context, @Nullable Uri parent, @NonNull Uri path, String tag) throws IOException { + this.tag = tag; + this.source = path.toString(); + + if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { + this.ioFile = new File(URI.create(this.source)); + } else { + DocumentFile file = DocumentFile.fromSingleUri(context, path); + + if (file == null) throw new RuntimeException("SAF not available"); + + this.context = context; + + if (file.getName() == null) { + this.source = null; + return; + } else { + this.docFile = file; + takePermissionSAF(); + } + } + + if (parent != null) { + if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) + this.docTree = DocumentFile.fromTreeUri(context, parent); + + this.sourceTree = parent.toString(); + } + + this.srcName = getName(); + this.srcType = getType(); + } + + + public static StoredFileHelper deserialize(@NonNull StoredFileHelper storage, Context context) throws IOException { + Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); + + if (storage.isInvalid()) + return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); + + StoredFileHelper instance = new StoredFileHelper(context, treeUri, Uri.parse(storage.source), storage.tag); + + // under SAF, if the target document is deleted, conserve the filename and mime + if (instance.srcName == null) instance.srcName = storage.srcName; + if (instance.srcType == null) instance.srcType = storage.srcType; + + return instance; + } + + public static void requestSafWithFileCreation(@NonNull Fragment who, int requestCode, String filename, String mime) { + // SAF notes: + // ACTION_OPEN_DOCUMENT Do not let you create the file, useful for overwrite files + // ACTION_CREATE_DOCUMENT No overwrite support, useless the file provider resolve the conflict + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType(mime) + .putExtra(Intent.EXTRA_TITLE, filename) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS) + .putExtra("android.content.extra.SHOW_ADVANCED", true);// hack, show all storage disks + + who.startActivityForResult(intent, requestCode); + } + + + public SharpStream getStream() throws IOException { + invalid(); + + if (docFile == null) + return new FileStream(ioFile); + else + return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); + } + + /** + * Indicates whatever if is possible access using the {@code java.io} API + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + invalid(); + + return docFile == null; + } + + public boolean isInvalid() { + return source == null; + } + + public Uri getUri() { + invalid(); + + return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + } + + public Uri getParentUri() { + invalid(); + + return sourceTree == null ? null : Uri.parse(sourceTree); + } + + public void truncate() throws IOException { + invalid(); + + try (SharpStream fs = getStream()) { + fs.setLength(0); + } + } + + public boolean delete() { + if (source == null) return true; + if (docFile == null) return ioFile.delete(); + + + boolean res = docFile.delete(); + + try { + int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); + } + } catch (Exception ex) { + // nothing to do + } + + return res; + } + + public long length() { + invalid(); + + return docFile == null ? ioFile.length() : docFile.length(); + } + + public boolean canWrite() { + if (source == null) return false; + return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + } + + public String getName() { + if (source == null) + return srcName; + else if (docFile == null) + return ioFile.getName(); + + String name = docFile.getName(); + return name == null ? srcName : name; + } + + public String getType() { + if (source == null || docFile == null) + return srcType; + + String type = docFile.getType(); + return type == null ? srcType : type; + } + + public String getTag() { + return tag; + } + + public boolean existsAsFile() { + if (source == null) return false; + + // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow + boolean exists = docFile == null ? ioFile.exists() : docFile.exists(); + boolean isFile = docFile == null ? ioFile.isFile() : docFile.isFile();// ¿docFile.isVirtual() means is no-physical? + + return exists && isFile; + } + + public boolean create() { + invalid(); + boolean result; + + if (docFile == null) { + try { + result = ioFile.createNewFile(); + } catch (IOException e) { + return false; + } + } else if (docTree == null) { + result = false; + } else { + if (!docTree.canRead() || !docTree.canWrite()) return false; + try { + docFile = createSAF(context, srcType, srcName); + if (docFile == null || docFile.getName() == null) return false; + result = true; + } catch (IOException e) { + return false; + } + } + + if (result) { + source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); + srcName = getName(); + srcType = getType(); + } + + return result; + } + + public void invalidate() { + if (source == null) return; + + srcName = getName(); + srcType = getType(); + + source = null; + + docTree = null; + docFile = null; + ioFile = null; + context = null; + } + + public boolean equals(StoredFileHelper storage) { + if (this == storage) return true; + + // note: do not compare tags, files can have the same parent folder + //if (stringMismatch(this.tag, storage.tag)) return false; + + if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) + return false; + + if (this.isInvalid() || storage.isInvalid()) { + return equalsIgnoreCase(this.srcName, storage.srcName) && equalsIgnoreCase(this.srcType, storage.srcType); + } + + if (this.isDirect() != storage.isDirect()) return false; + + if (this.isDirect()) + return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + + return getDocumentId( + this.docFile.getUri() + ).equalsIgnoreCase(getDocumentId( + storage.docFile.getUri() + )); + } + + @NonNull + @Override + public String toString() { + if (source == null) + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; + else + return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; + } + + + private void invalid() { + if (source == null) + throw new IllegalStateException("In invalid state"); + } + + private void takePermissionSAF() throws IOException { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + context.getContentResolver().takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); + } + } catch (Exception e) { + if (docFile.getName() == null) throw new IOException(e); + } + } + + private DocumentFile createSAF(@Nullable Context context, String mime, String filename) throws IOException { + DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(context, docTree, filename); + + if (res != null && res.exists() && res.isDirectory()) { + if (!res.delete()) + throw new IOException("Directory with the same name found but cannot delete"); + res = null; + } + + if (res == null) { + res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); + if (res == null) throw new IOException("Cannot create the file"); + } + + return res; + } + + private String getLowerCase(String str) { + return str == null ? null : str.toLowerCase(); + } + + private boolean stringMismatch(String str1, String str2) { + if (str1 == null && str2 == null) return false; + if ((str1 == null) != (str2 == null)) return true; + + return !str1.equals(str2); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java new file mode 100644 index 000000000..799cc2a75 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java @@ -0,0 +1,41 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipelegacy.streams.Mp4DashReader; +import org.schabi.newpipelegacy.streams.Mp4FromDashWriter; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; + +class M4aNoDash extends Postprocessing { + + M4aNoDash() { + super(false, true, ALGORITHM_M4A_NO_DASH); + } + + @Override + boolean test(SharpStream... sources) throws IOException { + // check if the mp4 file is DASH (youtube) + + Mp4DashReader reader = new Mp4DashReader(sources[0]); + reader.parse(); + + switch (reader.getBrands()[0]) { + case 0x64617368:// DASH + case 0x69736F35:// ISO5 + return true; + default: + return false; + } + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources[0]); + muxer.setMainBrand(0x4D344120);// binary string "M4A " + muxer.parseSources(); + muxer.selectTracks(0); + muxer.build(out); + + return OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java new file mode 100644 index 000000000..4616251da --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java @@ -0,0 +1,27 @@ +package us.shandian.giga.postprocessing; + +import org.schabi.newpipelegacy.streams.Mp4FromDashWriter; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; + +/** + * @author kapodamy + */ +class Mp4FromDashMuxer extends Postprocessing { + + Mp4FromDashMuxer() { + super(true, true, ALGORITHM_MP4_FROM_DASH_MUXER); + } + + @Override + int process(SharpStream out, SharpStream... sources) throws IOException { + Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources); + muxer.parseSources(); + muxer.selectTracks(0, 0); + muxer.build(out); + + return OK_RESULT; + } + +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java new file mode 100644 index 000000000..62f527320 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java @@ -0,0 +1,44 @@ +package us.shandian.giga.postprocessing; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.OggFromWebMWriter; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; +import java.nio.ByteBuffer; + +class OggFromWebmDemuxer extends Postprocessing { + + OggFromWebmDemuxer() { + super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER); + } + + @Override + boolean test(SharpStream... sources) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(4); + sources[0].read(buffer.array()); + + // youtube uses WebM as container, but the file extension (format suffix) is "*.opus" + // check if the file is a webm/mkv file before proceed + + switch (buffer.getInt()) { + case 0x1a45dfa3: + return true;// webm/mkv + case 0x4F676753: + return false;// ogg + } + + throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream"); + } + + @Override + int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { + OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out); + demuxer.parseSource(); + demuxer.selectTrack(0); + demuxer.build(); + + return OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java new file mode 100644 index 000000000..aea32f86d --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -0,0 +1,260 @@ +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.ChunkFileInputStream; +import us.shandian.giga.io.CircularFileWriter; +import us.shandian.giga.io.CircularFileWriter.OffsetChecker; +import us.shandian.giga.io.ProgressReport; + +import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; + +public abstract class Postprocessing implements Serializable { + + static transient final byte OK_RESULT = ERROR_NOTHING; + + public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; + public transient static final String ALGORITHM_WEBM_MUXER = "webm"; + public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; + public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; + + public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args) { + Postprocessing instance; + + switch (algorithmName) { + case ALGORITHM_TTML_CONVERTER: + instance = new TtmlConverter(); + break; + case ALGORITHM_WEBM_MUXER: + instance = new WebMMuxer(); + break; + case ALGORITHM_MP4_FROM_DASH_MUXER: + instance = new Mp4FromDashMuxer(); + break; + case ALGORITHM_M4A_NO_DASH: + instance = new M4aNoDash(); + break; + case ALGORITHM_OGG_FROM_WEBM_DEMUXER: + instance = new OggFromWebmDemuxer(); + break; + /*case "example-algorithm": + instance = new ExampleAlgorithm();*/ + default: + throw new UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName); + } + + instance.args = args; + return instance; + } + + /** + * Get a boolean value that indicate if the given algorithm work on the same + * file + */ + public boolean worksOnSameFile; + + /** + * Indicates whether the selected algorithm needs space reserved at the beginning of the file + */ + public boolean reserveSpace; + + /** + * Gets the given algorithm short name + */ + private String name; + + + private String[] args; + + private transient DownloadMission mission; + + private transient File tempFile; + + Postprocessing(boolean reserveSpace, boolean worksOnSameFile, String algorithmName) { + this.reserveSpace = reserveSpace; + this.worksOnSameFile = worksOnSameFile; + this.name = algorithmName;// for debugging only + } + + public void setTemporalDir(@NonNull File directory) { + long rnd = (int) (Math.random() * 100000f); + tempFile = new File(directory, rnd + "_" + System.nanoTime() + ".tmp"); + } + + public void cleanupTemporalDir() { + if (tempFile != null && tempFile.exists()) { + try { + //noinspection ResultOfMethodCallIgnored + tempFile.delete(); + } catch (Exception e) { + // nothing to do + } + } + } + + + public void run(DownloadMission target) throws IOException { + this.mission = target; + + CircularFileWriter out = null; + int result; + long finalLength = -1; + + mission.done = 0; + + long length = mission.storage.length() - mission.offsets[0]; + mission.length = length > mission.nearLength ? length : mission.nearLength; + + final ProgressReport readProgress = (long position) -> { + position -= mission.offsets[0]; + if (position > mission.done) mission.done = position; + }; + + if (worksOnSameFile) { + ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; + try { + for (int i = 0, j = 1; i < sources.length; i++, j++) { + SharpStream source = mission.storage.getStream(); + long end = j < sources.length ? mission.offsets[j] : source.length(); + + sources[i] = new ChunkFileInputStream(source, mission.offsets[i], end, readProgress); + } + + if (test(sources)) { + for (SharpStream source : sources) source.rewind(); + + OffsetChecker checker = () -> { + for (ChunkFileInputStream source : sources) { + /* + * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) + * or the CircularFileWriter can lead to unexpected results + */ + if (source.isClosed() || source.available() < 1) { + continue;// the selected source is not used anymore + } + + return source.getFilePointer() - 1; + } + + return -1; + }; + + out = new CircularFileWriter(mission.storage.getStream(), tempFile, checker); + out.onProgress = (long position) -> mission.done = position; + + out.onWriteError = (err) -> { + mission.psState = 3; + mission.notifyError(ERROR_POSTPROCESSING_HOLD, err); + + try { + synchronized (this) { + while (mission.psState == 3) + wait(); + } + } catch (InterruptedException e) { + // nothing to do + Log.e(this.getClass().getSimpleName(), "got InterruptedException"); + } + + return mission.errCode == ERROR_NOTHING; + }; + + result = process(out, sources); + + if (result == OK_RESULT) + finalLength = out.finalizeFile(); + } else { + result = OK_RESULT; + } + } finally { + for (SharpStream source : sources) { + if (source != null && !source.isClosed()) { + source.close(); + } + } + if (out != null) { + out.close(); + } + if (tempFile != null) { + //noinspection ResultOfMethodCallIgnored + tempFile.delete(); + tempFile = null; + } + } + } else { + result = test() ? process(null) : OK_RESULT; + } + + if (result == OK_RESULT) { + if (finalLength != -1) { + mission.length = finalLength; + } + } else { + mission.errCode = ERROR_POSTPROCESSING; + mission.errObject = new RuntimeException("post-processing algorithm returned " + result); + } + + if (result != OK_RESULT && worksOnSameFile) mission.storage.delete(); + + this.mission = null; + } + + /** + * Test if the post-processing algorithm can be skipped + * + * @param sources files to be processed + * @return {@code true} if the post-processing is required, otherwise, {@code false} + * @throws IOException if an I/O error occurs. + */ + boolean test(SharpStream... sources) throws IOException { + return true; + } + + /** + * Abstract method to execute the post-processing algorithm + * + * @param out output stream + * @param sources files to be processed + * @return an error code, {@code OK_RESULT} means the operation was successful + * @throws IOException if an I/O error occurs. + */ + abstract int process(SharpStream out, SharpStream... sources) throws IOException; + + String getArgumentAt(int index, String defaultValue) { + if (args == null || index >= args.length) { + return defaultValue; + } + + return args[index]; + } + + @NonNull + @Override + public String toString() { + StringBuilder str = new StringBuilder(); + + str.append("{ name=").append(name).append('['); + + if (args != null) { + for (String arg : args) { + str.append(", "); + str.append(arg); + } + str.delete(0, 1); + } + + return str.append("] }").toString(); + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java new file mode 100644 index 000000000..67e1fd88b --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java @@ -0,0 +1,50 @@ +package us.shandian.giga.postprocessing; + +import android.util.Log; + +import org.schabi.newpipelegacy.streams.SrtFromTtmlWriter; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; + +/** + * @author kapodamy + */ +class TtmlConverter extends Postprocessing { + private static final String TAG = "TtmlConverter"; + + TtmlConverter() { + // due how XmlPullParser works, the xml is fully loaded on the ram + super(false, true, ALGORITHM_TTML_CONVERTER); + } + + @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); + boolean ignoreEmptyFrames = getArgumentAt(1, "true").equals("true"); + + if (format == null || format.equals("ttml")) { + SrtFromTtmlWriter writer = new SrtFromTtmlWriter(out, ignoreEmptyFrames); + + try { + writer.build(sources[0]); + } catch (Exception err) { + Log.e(TAG, "subtitle parse failed", err); + return err instanceof IOException ? 1 : 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..2871ca956 --- /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.newpipelegacy.streams.WebMReader.TrackKind; +import org.schabi.newpipelegacy.streams.WebMReader.WebMTrack; +import org.schabi.newpipelegacy.streams.WebMWriter; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.IOException; + +/** + * @author kapodamy + */ +class WebMMuxer extends Postprocessing { + + WebMMuxer() { + super(true, true, ALGORITHM_WEBM_MUXER); + } + + @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" + int[] indexes = new int[sources.length]; + + for (int i = 0; i < sources.length; i++) { + WebMTrack[] tracks = muxer.getTracksFromSource(i); + for (int j = 0; j < tracks.length; j++) { + if (tracks[j].kind == TrackKind.Audio) { + indexes[i] = j; + i = sources.length; + break; + } + } + } + + muxer.selectTracks(indexes); + muxer.build(out); + + return OK_RESULT; + } + +} 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..cc02edd96 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -0,0 +1,706 @@ +package us.shandian.giga.service; + +import android.content.Context; +import android.os.Handler; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.DiffUtil; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +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.FinishedMissionStore; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.util.Utility; + +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; + +public class DownloadManager { + private static final String TAG = DownloadManager.class.getSimpleName(); + + enum NetworkState {Unavailable, Operating, MeteredOperating} + + public final static int SPECIAL_NOTHING = 0; + public final static int SPECIAL_PENDING = 1; + public final static int SPECIAL_FINISHED = 2; + + public static final String TAG_AUDIO = "audio"; + public static final String TAG_VIDEO = "video"; + private static final String DOWNLOADS_METADATA_FOLDER = "pending_downloads"; + + private final FinishedMissionStore mFinishedMissionStore; + + 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 mPrefMeteredDownloads; + boolean mPrefQueueLimit; + private boolean mSelfMissionsControl; + + StoredDirectoryHelper mMainStorageAudio; + StoredDirectoryHelper mMainStorageVideo; + + /** + * 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, StoredDirectoryHelper storageVideo, StoredDirectoryHelper storageAudio) { + if (DEBUG) { + Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); + } + + mFinishedMissionStore = new FinishedMissionStore(context); + mHandler = handler; + mMainStorageAudio = storageAudio; + mMainStorageVideo = storageVideo; + mMissionsFinished = loadFinishedMissions(); + mPendingMissionsDir = getPendingDir(context); + + loadPendingMissions(context); + } + + private static File getPendingDir(@NonNull Context context) { + File dir = context.getExternalFilesDir(DOWNLOADS_METADATA_FOLDER); + if (testDir(dir)) return dir; + + dir = new File(context.getFilesDir(), DOWNLOADS_METADATA_FOLDER); + if (testDir(dir)) return dir; + + throw new RuntimeException("path to pending downloads are not accessible"); + } + + private static boolean testDir(@Nullable File dir) { + if (dir == null) return false; + + try { + if (!Utility.mkdir(dir, false)) { + Log.e(TAG, "testDir() cannot create the directory in path: " + dir.getAbsolutePath()); + return false; + } + + File tmp = new File(dir, ".tmp"); + if (!tmp.createNewFile()) return false; + return tmp.delete();// if the file was created, SHOULD BE deleted too + } catch (Exception e) { + Log.e(TAG, "testDir() failed: " + dir.getAbsolutePath(), e); + return false; + } + } + + /** + * Loads finished missions from the data source + */ + private ArrayList loadFinishedMissions() { + ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); + + // check if the files exists, otherwise, forget the download + for (int i = finishedMissions.size() - 1; i >= 0; i--) { + FinishedMission mission = finishedMissions.get(i); + + if (!mission.storage.existsAsFile()) { + if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()); + + mFinishedMissionStore.deleteMission(mission); + finishedMissions.remove(i); + } + } + + return finishedMissions; + } + + private void loadPendingMissions(Context ctx) { + 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()); + } + + File tempDir = pickAvailableTemporalDir(ctx); + Log.i(TAG, "using '" + tempDir + "' as temporal directory"); + + for (File sub : subs) { + if (!sub.isFile()) continue; + if (sub.getName().equals(".tmp")) continue; + + DownloadMission mis = Utility.readFromFile(sub); + if (mis == null || mis.isFinished()) { + //noinspection ResultOfMethodCallIgnored + sub.delete(); + continue; + } + + mis.threads = new Thread[0]; + + boolean exists; + try { + mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); + exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); + } catch (Exception ex) { + Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex); + mis.storage.invalidate(); + exists = false; + } + + if (mis.isPsRunning()) { + if (mis.psAlgorithm.worksOnSameFile) { + // Incomplete post-processing results in a corrupted download file + // because the selected algorithm works on the same file to save space. + // the file will be deleted if the storage API + // is Java IO (avoid showing the "Save as..." dialog) + if (exists && mis.storage.isDirect() && !mis.storage.delete()) + Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); + } + + mis.psState = 0; + mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; + } else if (!exists) { + tryRecover(mis); + + // the progress is lost, reset mission state + if (mis.isInitialized()) + mis.resetState(true, true, DownloadMission.ERROR_PROGRESS_LOST); + } + + if (mis.psAlgorithm != null) { + mis.psAlgorithm.cleanupTemporalDir(); + mis.psAlgorithm.setTemporalDir(tempDir); + } + + mis.metadata = sub; + mis.maxRetry = mPrefMaxRetry; + 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 mission the new download mission to add and run (if possible) + */ + void startMission(DownloadMission mission) { + synchronized (this) { + mission.timestamp = System.currentTimeMillis(); + mission.mHandler = mHandler; + mission.maxRetry = mPrefMaxRetry; + + // create metadata file + 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(); + } + + mSelfMissionsControl = true; + mMissionsPending.add(mission); + + // Before continue, save the metadata in case the internet connection is not available + Utility.writeToFile(mission.metadata, mission); + + if (mission.storage == null) { + // noting to do here + mission.errCode = DownloadMission.ERROR_FILE_CREATION; + if (mission.errObject != null) + mission.errObject = new IOException("DownloadMission.storage == NULL"); + return; + } + + boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; + + if (canDownloadInCurrentNetwork() && start) { + mission.start(); + } + } + } + + + public void resumeMission(DownloadMission mission) { + if (!mission.running) { + mission.start(); + } + } + + public void pauseMission(DownloadMission mission) { + if (mission.running) { + mission.setEnqueued(false); + mission.pause(); + } + } + + public void deleteMission(Mission mission) { + synchronized (this) { + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mFinishedMissionStore.deleteMission(mission); + } + + mission.delete(); + } + } + + public void forgetMission(StoredFileHelper storage) { + synchronized (this) { + Mission mission = getAnyMission(storage); + if (mission == null) return; + + if (mission instanceof DownloadMission) { + mMissionsPending.remove(mission); + } else if (mission instanceof FinishedMission) { + mMissionsFinished.remove(mission); + mFinishedMissionStore.deleteMission(mission); + } + + mission.storage = null; + mission.delete(); + } + } + + public void tryRecover(DownloadMission mission) { + StoredDirectoryHelper mainStorage = getMainStorage(mission.storage.getTag()); + + if (!mission.storage.isInvalid() && mission.storage.create()) return; + + // using javaIO cannot recreate the file + // using SAF in older devices (no tree available) + // + // force the user to pick again the save path + mission.storage.invalidate(); + + if (mainStorage == null) return; + + // if the user has changed the save path before this download, the original save path will be lost + StoredFileHelper newStorage = mainStorage.createFile(mission.storage.getName(), mission.storage.getType()); + + if (newStorage != null) mission.storage = newStorage; + } + + + /** + * Get a pending mission by its path + * + * @param storage where the file possible is stored + * @return the mission or null if no such mission exists + */ + @Nullable + private DownloadMission getPendingMission(StoredFileHelper storage) { + for (DownloadMission mission : mMissionsPending) { + if (mission.storage.equals(storage)) { + return mission; + } + } + return null; + } + + /** + * Get a finished mission by its path + * + * @param storage where the file possible is stored + * @return the mission index or -1 if no such mission exists + */ + private int getFinishedMissionIndex(StoredFileHelper storage) { + for (int i = 0; i < mMissionsFinished.size(); i++) { + if (mMissionsFinished.get(i).storage.equals(storage)) { + return i; + } + } + + return -1; + } + + private Mission getAnyMission(StoredFileHelper storage) { + synchronized (this) { + Mission mission = getPendingMission(storage); + if (mission != null) return mission; + + int idx = getFinishedMissionIndex(storage); + if (idx >= 0) return mMissionsFinished.get(idx); + } + + return null; + } + + int getRunningMissionsCount() { + int count = 0; + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running && !mission.isPsFailed() && !mission.isFinished()) + count++; + } + } + + return count; + } + + public void pauseAllMissions(boolean force) { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue; + + if (force) { + // avoid waiting for threads + mission.init = null; + mission.threads = new Thread[0]; + } + + mission.pause(); + } + } + } + + public void startAllMissions() { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.running || mission.isCorrupt()) continue; + + mission.start(); + } + } + } + + /** + * 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)); + mFinishedMissionStore.addFinishedMission(mission); + } + } + + /** + * runs one or multiple missions in from queue if possible + * + * @return true if one or multiple missions are running, otherwise, false + */ + boolean runMissions() { + synchronized (this) { + if (mMissionsPending.size() < 1) return false; + if (!canDownloadInCurrentNetwork()) return false; + + if (mPrefQueueLimit) { + for (DownloadMission mission : mMissionsPending) + if (!mission.isFinished() && mission.running) return true; + } + + boolean flag = false; + for (DownloadMission mission : mMissionsPending) { + if (mission.running || !mission.enqueued || mission.isFinished()) + continue; + + resumeMission(mission); + if (mission.errCode != DownloadMission.ERROR_NOTHING) continue; + + if (mPrefQueueLimit) return true; + flag = true; + } + + return flag; + } + } + + public MissionIterator getIterator() { + mSelfMissionsControl = true; + return new MissionIterator(); + } + + /** + * Forget all finished downloads, but, doesn't delete any file + */ + public void forgetFinishedDownloads() { + synchronized (this) { + for (FinishedMission mission : mMissionsFinished) { + mFinishedMissionStore.deleteMission(mission); + } + mMissionsFinished.clear(); + } + } + + private boolean canDownloadInCurrentNetwork() { + if (mLastNetworkStatus == NetworkState.Unavailable) return false; + return !(mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating); + } + + void handleConnectivityState(NetworkState currentStatus, boolean updateOnly) { + if (currentStatus == mLastNetworkStatus) return; + + mLastNetworkStatus = currentStatus; + if (currentStatus == NetworkState.Unavailable) return; + + if (!mSelfMissionsControl || updateOnly) { + return;// don't touch anything without the user interaction + } + + boolean isMetered = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating; + + synchronized (this) { + for (DownloadMission mission : mMissionsPending) { + if (mission.isCorrupt() || mission.isPsRunning()) continue; + + if (mission.running && isMetered) { + mission.pause(); + } else if (!mission.running && !isMetered && mission.enqueued) { + mission.start(); + if (mPrefQueueLimit) break; + } + } + } + } + + void updateMaximumAttempts() { + synchronized (this) { + for (DownloadMission mission : mMissionsPending) mission.maxRetry = mPrefMaxRetry; + } + } + + public MissionState checkForExistingMission(StoredFileHelper storage) { + synchronized (this) { + DownloadMission pending = getPendingMission(storage); + + if (pending == null) { + if (getFinishedMissionIndex(storage) >= 0) return MissionState.Finished; + } else { + if (pending.isFinished()) { + return MissionState.Finished;// this never should happen (race-condition) + } else { + return pending.running ? MissionState.PendingRunning : MissionState.Pending; + } + } + } + + return MissionState.None; + } + + private static boolean isDirectoryAvailable(File directory) { + return directory != null && directory.canWrite() && directory.exists(); + } + + static File pickAvailableTemporalDir(@NonNull Context ctx) { + File dir = ctx.getExternalFilesDir(null); + if (isDirectoryAvailable(dir)) return dir; + + dir = ctx.getFilesDir(); + if (isDirectoryAvailable(dir)) return dir; + + // this never should happen + dir = ctx.getDir("muxing_tmp", Context.MODE_PRIVATE); + if (isDirectoryAvailable(dir)) return dir; + + // fallback to cache dir + dir = ctx.getCacheDir(); + if (isDirectoryAvailable(dir)) return dir; + + throw new RuntimeException("Not temporal directories are available"); + } + + @Nullable + private StoredDirectoryHelper getMainStorage(@NonNull String tag) { + if (tag.equals(TAG_AUDIO)) return mMainStorageAudio; + if (tag.equals(TAG_VIDEO)) return mMainStorageVideo; + + Log.w(TAG, "Unknown download category, not [audio video]: " + tag); + + return null;// this never should happen + } + + public class MissionIterator extends DiffUtil.Callback { + final Object FINISHED = new Object(); + final Object PENDING = new Object(); + + ArrayList snapshot; + ArrayList current; + ArrayList hidden; + + boolean hasFinished = false; + + 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); + } + + hasFinished = finished.size() > 0; + + 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 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); + } + + public boolean hasFinishedMissions() { + return hasFinished; + } + + /** + * Check if exists missions running and paused. Corrupted and hidden missions are not counted + * + * @return two-dimensional array contains the current missions state. + * 1° entry: true if has at least one mission running + * 2° entry: true if has at least one mission paused + */ + public boolean[] hasValidPendingMissions() { + boolean running = false; + boolean paused = false; + + synchronized (DownloadManager.this) { + for (DownloadMission mission : mMissionsPending) { + if (hidden.contains(mission) || mission.isCorrupt()) + continue; + + if (mission.running) + running = true; + else + paused = true; + } + } + + return new boolean[]{running, paused}; + } + + + @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) { + Object x = snapshot.get(oldItemPosition); + Object y = current.get(newItemPosition); + + if (x instanceof Mission && y instanceof Mission) { + return ((Mission) x).storage.equals(((Mission) y).storage); + } + + return false; + } + } + + 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 new file mode 100644 index 000000000..daa8fd5fd --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -0,0 +1,628 @@ +package us.shandian.giga.service; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkInfo; +import android.net.NetworkRequest; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.Handler.Callback; +import android.os.IBinder; +import android.os.Message; +import android.os.Parcelable; +import android.preference.PreferenceManager; +import android.util.Log; +import android.util.SparseArray; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationCompat.Builder; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.download.DownloadActivity; +import org.schabi.newpipelegacy.player.helper.LockManager; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.MissionRecoveryInfo; +import us.shandian.giga.io.StoredDirectoryHelper; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.postprocessing.Postprocessing; +import us.shandian.giga.service.DownloadManager.NetworkState; + +import static org.schabi.newpipelegacy.BuildConfig.APPLICATION_ID; +import static org.schabi.newpipelegacy.BuildConfig.DEBUG; + +public class DownloadManagerService extends Service { + + private static final String TAG = "DownloadManagerService"; + + public static final int MESSAGE_RUNNING = 0; + public static final int MESSAGE_PAUSED = 1; + public static final int MESSAGE_FINISHED = 2; + public static final int MESSAGE_ERROR = 3; + public static final int MESSAGE_DELETED = 4; + + private static final int FOREGROUND_NOTIFICATION_ID = 1000; + private static final int DOWNLOADS_NOTIFICATION_ID = 1001; + + private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; + 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 EXTRA_PATH = "DownloadManagerService.extra.storagePath"; + private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; + private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; + private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; + + private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; + private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; + + private DownloadManagerBinder mBinder; + private DownloadManager mManager; + private Notification mNotification; + private Handler mHandler; + private boolean mForeground = false; + private NotificationManager mNotificationManager = null; + private boolean mDownloadNotificationEnable = true; + + private int downloadDoneCount = 0; + private Builder downloadDoneNotification = null; + private StringBuilder downloadDoneList = null; + + private final ArrayList mEchoObservers = new ArrayList<>(1); + + private ConnectivityManager mConnectivityManager; + private BroadcastReceiver mNetworkStateListener = null; + private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; + + private SharedPreferences mPrefs = null; + private final 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 uri + */ + private void notifyMediaScanner(Uri file) { + sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); + } + + @Override + public void onCreate() { + super.onCreate(); + + if (DEBUG) { + Log.d(TAG, "onCreate"); + } + + mBinder = new DownloadManagerBinder(); + mHandler = new Handler(this::handleMessage); + + mPrefs = PreferenceManager.getDefaultSharedPreferences(this); + + mManager = new DownloadManager(this, mHandler, loadMainVideoStorage(), loadMainAudioStorage()); + + Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) + .setAction(Intent.ACTION_MAIN); + + mOpenDownloadList = PendingIntent.getActivity(this, 0, + openDownloadListIntent, + PendingIntent.FLAG_UPDATE_CURRENT); + + icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); + + Builder builder = new Builder(this, getString(R.string.notification_channel_id)) + .setContentIntent(mOpenDownloadList) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setLargeIcon(icLauncher) + .setContentTitle(getString(R.string.msg_running)) + .setContentText(getString(R.string.msg_running_detail)); + + mNotification = builder.build(); + + mNotificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + mConnectivityManager = (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); + + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handleConnectivityState(false); + } + + @Override + public void onLost(Network network) { + handleConnectivityState(false); + } + }; + mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); + } else { + mNetworkStateListener = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + handleConnectivityState(false); + } + }; + registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); + } + + mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); + + handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); + handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); + + mLock = new LockManager(this); + } + + @Override + public int onStartCommand(final Intent intent, int flags, int startId) { + if (DEBUG) { + Log.d(TAG, intent == null ? "Restarting" : "Starting"); + } + + if (intent == null) return START_NOT_STICKY; + + Log.i(TAG, "Got intent: " + intent); + String action = intent.getAction(); + if (action != null) { + if (action.equals(Intent.ACTION_RUN)) { + mHandler.post(() -> startMission(intent)); + } 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; + } + } + + return START_STICKY; + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (DEBUG) { + Log.d(TAG, "Destroying"); + } + + stopForeground(true); + + if (mNotificationManager != null && downloadDoneNotification != null) { + downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); + } + + manageLock(false); + + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); + else + unregisterReceiver(mNetworkStateListener); + + mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); + + if (icDownloadDone != null) icDownloadDone.recycle(); + if (icDownloadFailed != null) icDownloadFailed.recycle(); + if (icLauncher != null) icLauncher.recycle(); + + mHandler = null; + mManager.pauseAllMissions(true); + } + + @Override + public IBinder onBind(Intent intent) { + /* + int permissionCheck; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); + if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { + Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); + } + } + + permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); + if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { + Toast.makeText(this, "Permission denied (write)", Toast.LENGTH_SHORT).show(); + } + */ + + return mBinder; + } + + private boolean handleMessage(@NonNull Message msg) { + if (mHandler == null) return true; + + DownloadMission mission = (DownloadMission) msg.obj; + + switch (msg.what) { + case MESSAGE_FINISHED: + notifyMediaScanner(mission.storage.getUri()); + notifyFinishedDownload(mission.storage.getName()); + mManager.setFinished(mission); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); + break; + case MESSAGE_RUNNING: + updateForegroundState(true); + break; + case MESSAGE_ERROR: + notifyFailedDownload(mission); + handleConnectivityState(false); + updateForegroundState(mManager.runMissions()); + break; + case MESSAGE_PAUSED: + updateForegroundState(mManager.getRunningMissionsCount() > 0); + break; + } + + if (msg.what != MESSAGE_ERROR) + mFailedDownloads.delete(mFailedDownloads.indexOfValue(mission)); + + for (Callback observer : mEchoObservers) + observer.handleMessage(msg); + + return true; + } + + private void handleConnectivityState(boolean updateOnly) { + NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); + NetworkState status; + + if (info == null) { + status = NetworkState.Unavailable; + Log.i(TAG, "Active network [connectivity is unavailable]"); + } else { + boolean connected = info.isConnected(); + boolean metered = mConnectivityManager.isActiveNetworkMetered(); + + if (connected) + status = metered ? NetworkState.MeteredOperating : NetworkState.Operating; + else + status = NetworkState.Unavailable; + + Log.i(TAG, "Active network [connected=" + connected + " metered=" + metered + "] " + info.toString()); + } + + if (mManager == null) return;// avoid race-conditions while the service is starting + mManager.handleConnectivityState(status, updateOnly); + } + + private void handlePreferenceChange(SharedPreferences prefs, @NonNull 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 = value == null ? 0 : Integer.parseInt(value); + } catch (Exception e) { + mManager.mPrefMaxRetry = 0; + } + mManager.updateMaximumAttempts(); + } else if (key.equals(getString(R.string.downloads_cross_network))) { + mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); + } else if (key.equals(getString(R.string.downloads_queue_limit))) { + mManager.mPrefQueueLimit = prefs.getBoolean(key, true); + } else if (key.equals(getString(R.string.download_path_video_key))) { + mManager.mMainStorageVideo = loadMainVideoStorage(); + } else if (key.equals(getString(R.string.download_path_audio_key))) { + mManager.mMainStorageAudio = loadMainAudioStorage(); + } + } + + public void updateForegroundState(boolean state) { + if (state == mForeground) return; + + if (state) { + startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); + } else { + stopForeground(true); + } + + manageLock(state); + + mForeground = state; + } + + /** + * Start a new download mission + * + * @param context the activity context + * @param urls array of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download + */ + public static void startMission(Context context, String[] urls, StoredFileHelper storage, + char kind, int threads, String source, String psName, + String[] psArgs, long nearLength, MissionRecoveryInfo[] recoveryInfo) { + Intent intent = new Intent(context, DownloadManagerService.class); + intent.setAction(Intent.ACTION_RUN); + intent.putExtra(EXTRA_URLS, urls); + 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); + intent.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo); + + intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri()); + intent.putExtra(EXTRA_PATH, storage.getUri()); + intent.putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + + context.startService(intent); + } + + private void startMission(Intent intent) { + String[] urls = intent.getStringArrayExtra(EXTRA_URLS); + Uri path = intent.getParcelableExtra(EXTRA_PATH); + Uri parentPath = intent.getParcelableExtra(EXTRA_PARENT_PATH); + 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); + String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); + Parcelable[] parcelRecovery = intent.getParcelableArrayExtra(EXTRA_RECOVERY_INFO); + + StoredFileHelper storage; + try { + storage = new StoredFileHelper(this, parentPath, path, tag); + } catch (IOException e) { + throw new RuntimeException(e);// this never should happen + } + + Postprocessing ps; + if (psName == null) + ps = null; + else + ps = Postprocessing.getAlgorithm(psName, psArgs); + + MissionRecoveryInfo[] recovery = new MissionRecoveryInfo[parcelRecovery.length]; + for (int i = 0; i < parcelRecovery.length; i++) + recovery[i] = (MissionRecoveryInfo) parcelRecovery[i]; + + final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); + mission.threadCount = threads; + mission.source = source; + mission.nearLength = nearLength; + mission.recoveryInfo = recovery; + + if (ps != null) + ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); + + handleConnectivityState(true);// first check the actual network status + + mManager.startMission(mission); + } + + public void notifyFinishedDownload(String name) { + if (!mDownloadNotificationEnable || mNotificationManager == null) { + return; + } + + 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)); + } + + 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); + } + + mNotificationManager.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.storage.getName()))); + } else { + downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); + downloadFailedNotification.setContentText(mission.storage.getName()); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mission.storage.getName())); + } + + mNotificationManager.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 manageLock(boolean acquire) { + if (acquire == mLockAcquired) return; + + if (acquire) + mLock.acquireWifiAndCpu(); + else + mLock.releaseWifiAndCpu(); + + mLockAcquired = acquire; + } + + private StoredDirectoryHelper loadMainVideoStorage() { + return loadMainStorage(R.string.download_path_video_key, DownloadManager.TAG_VIDEO); + } + + private StoredDirectoryHelper loadMainAudioStorage() { + return loadMainStorage(R.string.download_path_audio_key, DownloadManager.TAG_AUDIO); + } + + private StoredDirectoryHelper loadMainStorage(@StringRes int prefKey, String tag) { + String path = mPrefs.getString(getString(prefKey), null); + + if (path == null || path.isEmpty()) return null; + + if (path.charAt(0) == File.separatorChar) { + Log.i(TAG, "Old save path style present: " + path); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + path = Uri.fromFile(new File(path)).toString(); + else + path = ""; + + mPrefs.edit().putString(getString(prefKey), "").apply(); + } + + try { + return new StoredDirectoryHelper(this, Uri.parse(path), tag); + } catch (Exception e) { + Log.e(TAG, "Failed to load the storage of " + tag + " from " + path, e); + Toast.makeText(this, R.string.no_available_dir, Toast.LENGTH_LONG).show(); + } + + return null; + } + + //////////////////////////////////////////////////////////////////////////////////////////////// + // Wrappers for DownloadManager + //////////////////////////////////////////////////////////////////////////////////////////////// + + public class DownloadManagerBinder extends Binder { + public DownloadManager getDownloadManager() { + return mManager; + } + + @Nullable + public StoredDirectoryHelper getMainStorageVideo() { + return mManager.mMainStorageVideo; + } + + @Nullable + public StoredDirectoryHelper getMainStorageAudio() { + return mManager.mMainStorageAudio; + } + + public boolean askForSavePath() { + return DownloadManagerService.this.mPrefs.getBoolean( + DownloadManagerService.this.getString(R.string.downloads_storage_ask), + false + ); + } + + public void addMissionEventListener(Callback handler) { + mEchoObservers.add(handler); + } + + public void removeMissionEventListener(Callback handler) { + mEchoObservers.remove(handler); + } + + public void clearDownloadNotifications() { + if (mNotificationManager == null) return; + if (downloadDoneNotification != null) { + mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); + downloadDoneList.setLength(0); + downloadDoneCount = 0; + } + if (downloadFailedNotification != null) { + for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { + mNotificationManager.cancel(downloadFailedNotificationID); + } + mFailedDownloads.clear(); + downloadFailedNotificationID++; + } + } + + public void enableNotifications(boolean enable) { + mDownloadNotificationEnable = enable; + } + + } + +} diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.java b/app/src/main/java/us/shandian/giga/service/MissionState.java new file mode 100644 index 000000000..2d7802ff5 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/MissionState.java @@ -0,0 +1,5 @@ +package us.shandian.giga.service; + +public enum MissionState { + None, Pending, PendingRunning, 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 new file mode 100644 index 000000000..812775deb --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -0,0 +1,1020 @@ +package us.shandian.giga.ui.adapter; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.util.SparseArray; +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.MimeTypeMap; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.Adapter; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import com.google.android.material.snackbar.Snackbar; + +import org.schabi.newpipelegacy.BuildConfig; +import org.schabi.newpipelegacy.R; +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipelegacy.report.ErrorActivity; +import org.schabi.newpipelegacy.report.UserAction; +import org.schabi.newpipelegacy.util.NavigationHelper; + +import java.io.File; +import java.lang.ref.WeakReference; +import java.net.URI; +import java.util.ArrayList; +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.MissionRecoveryInfo; +import us.shandian.giga.io.StoredFileHelper; +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_ACTIVITY_NEW_TASK; +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_INSUFFICIENT_STORAGE; +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; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; +import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; +import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; +import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; +import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; + +public class MissionAdapter extends Adapter implements Handler.Callback { + private static final SparseArray ALGORITHMS = new SparseArray<>(); + private static final String TAG = "MissionAdapter"; + private static final String UNDEFINED_PROGRESS = "--.-%"; + private static final String DEFAULT_MIME_TYPE = "*/*"; + private static final String UNDEFINED_ETA = "--:--"; + + + static { + ALGORITHMS.put(R.id.md5, "MD5"); + ALGORITHMS.put(R.id.sha1, "SHA1"); + } + + private Context mContext; + private LayoutInflater mInflater; + private DownloadManager mDownloadManager; + private Deleter mDeleter; + private int mLayout; + private DownloadManager.MissionIterator mIterator; + private ArrayList mPendingDownloadsItems = new ArrayList<>(); + private Handler mHandler; + private MenuItem mClear; + private MenuItem mStartButton; + private MenuItem mPauseButton; + private View mEmptyMessage; + private RecoverHelper mRecover; + private View mView; + private ArrayList mHidden; + private Snackbar mSnackbar; + + private final Runnable rUpdater = this::updater; + private final Runnable rDelete = this::deleteFinishedDownloads; + + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { + mContext = context; + mDownloadManager = downloadManager; + + mInflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mLayout = R.layout.mission_item; + + mHandler = new Handler(context.getMainLooper()); + + mEmptyMessage = emptyMessage; + + mIterator = downloadManager.getIterator(); + + mDeleter = new Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler); + + mView = root; + + mHidden = new ArrayList<>(); + + checkEmptyMessageVisibility(); + onResume(); + } + + @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 void onViewRecycled(@NonNull ViewHolder view) { + super.onViewRecycled(view); + + if (view instanceof ViewHolderHeader) return; + ViewHolderItem h = (ViewHolderItem) view; + + if (h.item.mission instanceof DownloadMission) { + mPendingDownloadsItems.remove(h); + if (mPendingDownloadsItems.size() < 1) { + checkMasterButtonsVisibility(); + } + } + + h.popupMenu.dismiss(); + h.item = null; + h.resetSpeedMeasure(); + } + + @Override + @SuppressLint("SetTextI18n") + public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { + DownloadManager.MissionItem item = mIterator.getItem(pos); + + 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; + if (mClear != null) mClear.setVisible(true); + } + + ((ViewHolderHeader) view).header.setText(str); + return; + } + + ViewHolderItem h = (ViewHolderItem) view; + h.item = item; + + Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); + + h.icon.setImageResource(Utility.getIconForFileType(type)); + h.name.setText(item.mission.storage.getName()); + + h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); + + if (h.item.mission instanceof DownloadMission) { + DownloadMission mission = (DownloadMission) item.mission; + String length = Utility.formatBytes(mission.getLength()); + if (mission.running && !mission.isPsRunning()) length += " --.- kB/s"; + + h.size.setText(length); + h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); + 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 mIterator.getOldListSize(); + } + + @Override + public int getItemViewType(int position) { + return mIterator.getSpecialAtItem(position); + } + + @SuppressLint("DefaultLocale") + private void updateProgress(ViewHolderItem h) { + if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; + + DownloadMission mission = (DownloadMission) h.item.mission; + double done = mission.done; + long length = mission.getLength(); + long now = System.currentTimeMillis(); + boolean hasError = mission.errCode != ERROR_NOTHING; + + // hide on error + // show if current resource length is not fetched + // show if length is unknown + h.progress.setMarquee(mission.isRecovering() || !hasError && (!mission.isInitialized() || mission.unknownLength)); + + double progress; + if (mission.unknownLength) { + progress = Double.NaN; + h.progress.setProgress(0f); + } else { + progress = done / length; + } + + if (hasError) { + h.progress.setProgress(isNotFinite(progress) ? 1d : progress); + h.status.setText(R.string.msg_error); + } else if (isNotFinite(progress)) { + h.status.setText(UNDEFINED_PROGRESS); + } else { + h.status.setText(String.format("%.2f%%", progress * 100)); + h.progress.setProgress(progress); + } + + @StringRes int state; + String sizeStr = Utility.formatBytes(length).concat(" "); + + if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { + h.size.setText(sizeStr); + return; + } else if (!mission.running) { + state = mission.enqueued ? R.string.queued : R.string.paused; + } else if (mission.isPsRunning()) { + state = R.string.post_processing; + } else if (mission.isRecovering()) { + state = R.string.recovering; + } else { + state = 0; + } + + if (state != 0) { + // update state without download speed + h.size.setText(sizeStr.concat("(").concat(mContext.getString(state)).concat(")")); + h.resetSpeedMeasure(); + return; + } + + if (h.lastTimestamp < 0) { + h.size.setText(sizeStr); + h.lastTimestamp = now; + h.lastDone = done; + return; + } + + long deltaTime = now - h.lastTimestamp; + double deltaDone = done - h.lastDone; + + if (h.lastDone > done) { + h.lastDone = done; + h.size.setText(sizeStr); + return; + } + + if (deltaDone > 0 && deltaTime > 0) { + float speed = (float) ((deltaDone * 1000d) / deltaTime); + float averageSpeed = speed; + + if (h.lastSpeedIdx < 0) { + for (int i = 0; i < h.lastSpeed.length; i++) { + h.lastSpeed[i] = speed; + } + h.lastSpeedIdx = 0; + } else { + for (int i = 0; i < h.lastSpeed.length; i++) { + averageSpeed += h.lastSpeed[i]; + } + averageSpeed /= h.lastSpeed.length + 1f; + } + + String speedStr = Utility.formatSpeed(averageSpeed); + String etaStr; + + if (mission.unknownLength) { + etaStr = ""; + } else { + long eta = (long) Math.ceil((length - done) / averageSpeed); + etaStr = Utility.formatBytes((long) done) + "/" + Utility.stringifySeconds(eta) + " "; + } + + h.size.setText(sizeStr.concat(etaStr).concat(speedStr)); + + h.lastTimestamp = now; + h.lastDone = done; + h.lastSpeed[h.lastSpeedIdx++] = speed; + + if (h.lastSpeedIdx >= h.lastSpeed.length) h.lastSpeedIdx = 0; + } + } + + private void viewWithFileProvider(Mission mission) { + if (checkInvalidFile(mission)) return; + + String mimeType = resolveMimeType(mission); + + if (BuildConfig.DEBUG) + Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); + + Uri uri = resolveShareableUri(mission); + + Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + 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); + } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + intent.addFlags(FLAG_ACTIVITY_NEW_TASK); + } + + //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + + if (intent.resolveActivity(mContext.getPackageManager()) != null) { + mContext.startActivity(intent); + } else { + Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); + } + } + + private void shareFile(Mission mission) { + if (checkInvalidFile(mission)) return; + + Intent intent = new Intent(Intent.ACTION_SEND); + intent.setType(resolveMimeType(mission)); + intent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + + mContext.startActivity(Intent.createChooser(intent, null)); + } + + /** + * Returns an Uri which can be shared to other applications. + * + * @see + * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed + */ + private Uri resolveShareableUri(Mission mission) { + if (mission.storage.isDirect()) { + return FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + new File(URI.create(mission.storage.getUri().toString())) + ); + } else { + return mission.storage.getUri(); + } + } + + private static String resolveMimeType(@NonNull Mission mission) { + String mimeType; + + if (!mission.storage.isInvalid()) { + mimeType = mission.storage.getType(); + if (mimeType != null && mimeType.length() > 0 && !mimeType.equals(StoredFileHelper.DEFAULT_MIME)) + return mimeType; + } + + String ext = Utility.getFileExt(mission.storage.getName()); + if (ext == null) return DEFAULT_MIME_TYPE; + + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); + + return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; + } + + private boolean checkInvalidFile(@NonNull Mission mission) { + if (mission.storage.existsAsFile()) return false; + + Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); + return true; + } + + private ViewHolderItem getViewHolder(Object mission) { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (h.item.mission == mission) return h; + } + return null; + } + + @Override + public boolean handleMessage(@NonNull Message msg) { + if (mStartButton != null && mPauseButton != null) { + checkMasterButtonsVisibility(); + } + + switch (msg.what) { + case DownloadManagerService.MESSAGE_ERROR: + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: + case DownloadManagerService.MESSAGE_PAUSED: + break; + default: + return false; + } + + ViewHolderItem h = getViewHolder(msg.obj); + if (h == null) return false; + + switch (msg.what) { + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: + // DownloadManager should mark the download as finished + applyChanges(); + return true; + } + + updateProgress(h); + return true; + } + + private void showError(@NonNull DownloadMission mission) { + @StringRes int msg = R.string.general_error; + String msgEx = null; + + switch (mission.errCode) { + case 416: + msg = R.string.error_http_unsupported_range; + break; + case 404: + msg = R.string.error_http_not_found; + break; + case ERROR_NOTHING: + return;// this never should happen + case ERROR_FILE_CREATION: + msg = R.string.error_file_creation; + break; + case ERROR_HTTP_NO_CONTENT: + msg = R.string.error_http_no_content; + break; + case ERROR_PATH_CREATION: + msg = R.string.error_path_creation; + break; + case ERROR_PERMISSION_DENIED: + msg = R.string.permission_denied; + break; + case ERROR_SSL_EXCEPTION: + msg = R.string.error_ssl_exception; + break; + case ERROR_UNKNOWN_HOST: + msg = R.string.error_unknown_host; + break; + case ERROR_CONNECT_HOST: + msg = R.string.error_connect_host; + break; + case ERROR_POSTPROCESSING_STOPPED: + msg = R.string.error_postprocessing_stopped; + break; + case ERROR_POSTPROCESSING: + case ERROR_POSTPROCESSING_HOLD: + showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); + return; + case ERROR_INSUFFICIENT_STORAGE: + msg = R.string.error_insufficient_storage; + break; + case ERROR_UNKNOWN_EXCEPTION: + if (mission.errObject != null) { + showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error); + return; + } else { + msg = R.string.msg_error; + break; + } + case ERROR_PROGRESS_LOST: + msg = R.string.error_progress_lost; + break; + case ERROR_TIMEOUT: + msg = R.string.error_timeout; + break; + case ERROR_RESOURCE_GONE: + msg = R.string.error_download_resource_gone; + break; + default: + if (mission.errCode >= 100 && mission.errCode < 600) { + msgEx = "HTTP " + mission.errCode; + } else if (mission.errObject == null) { + msgEx = "(not_decelerated_error_code)"; + } else { + showError(mission, UserAction.DOWNLOAD_FAILED, msg); + return; + } + break; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(mContext); + + if (msgEx != null) + builder.setMessage(msgEx); + else + builder.setMessage(msg); + + // add report button for non-HTTP errors (range 100-599) + if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { + @StringRes final int mMsg = msg; + builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> + showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) + ); + } + + builder.setNegativeButton(R.string.finish, (dialog, which) -> dialog.cancel()) + .setTitle(mission.storage.getName()) + .create() + .show(); + } + + private void showError(DownloadMission mission, UserAction action, @StringRes int reason) { + StringBuilder request = new StringBuilder(256); + request.append(mission.source); + + request.append(" ["); + if (mission.recoveryInfo != null) { + for (MissionRecoveryInfo recovery : mission.recoveryInfo) + request.append(' ') + .append(recovery.toString()) + .append(' '); + } + request.append("]"); + + String service; + try { + service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName(); + } catch (Exception e) { + service = "-"; + } + + ErrorActivity.reportError( + mContext, + mission.errObject, + null, + null, + ErrorActivity.ErrorInfo.make(action, service, request.toString(), reason) + ); + } + + public void clearFinishedDownloads(boolean delete) { + if (delete && mIterator.hasFinishedMissions() && mHidden.isEmpty()) { + for (int i = 0; i < mIterator.getOldListSize(); i++) { + FinishedMission mission = mIterator.getItem(i).mission instanceof FinishedMission ? (FinishedMission) mIterator.getItem(i).mission : null; + if (mission != null) { + mIterator.hide(mission); + mHidden.add(mission); + } + } + applyChanges(); + + String msg = String.format(mContext.getString(R.string.deleted_downloads), mHidden.size()); + mSnackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); + mSnackbar.setAction(R.string.undo, s -> { + Iterator i = mHidden.iterator(); + while (i.hasNext()) { + mIterator.unHide(i.next()); + i.remove(); + } + applyChanges(); + mHandler.removeCallbacks(rDelete); + }); + mSnackbar.setActionTextColor(Color.YELLOW); + mSnackbar.show(); + + mHandler.postDelayed(rDelete, 5000); + } else if (!delete) { + mDownloadManager.forgetFinishedDownloads(); + applyChanges(); + } + } + + private void deleteFinishedDownloads() { + if (mSnackbar != null) mSnackbar.dismiss(); + + Iterator i = mHidden.iterator(); + while (i.hasNext()) { + Mission mission = i.next(); + if (mission != null) { + mDownloadManager.deleteMission(mission); + mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); + } + i.remove(); + } + } + + private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { + if (h.item == null) return true; + + 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.status.setText(UNDEFINED_PROGRESS); + mDownloadManager.resumeMission(mission); + return true; + case R.id.pause: + mDownloadManager.pauseMission(mission); + return true; + case R.id.error_message_view: + showError(mission); + return true; + case R.id.queue: + boolean flag = !h.queue.isChecked(); + h.queue.setChecked(flag); + mission.setEnqueued(flag); + updateProgress(h); + return true; + case R.id.retry: + if (mission.isPsRunning()) { + mission.psContinue(true); + } else { + mDownloadManager.tryRecover(mission); + if (mission.storage.isInvalid()) + mRecover.tryRecover(mission); + else + recoverMission(mission); + } + return true; + case R.id.cancel: + mission.psContinue(false); + return false; + } + } + + switch (id) { + case R.id.menu_item_share: + shareFile(h.item.mission); + return true; + case R.id.delete: + mDeleter.append(h.item.mission); + applyChanges(); + checkMasterButtonsVisibility(); + return true; + case R.id.md5: + case R.id.sha1: + new ChecksumTask(mContext).execute(h.item.mission.storage, 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; + } + } + + public void applyChanges() { + mIterator.start(); + DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); + mIterator.end(); + + checkEmptyMessageVisibility(); + if (mClear != null) mClear.setVisible(mIterator.hasFinishedMissions()); + } + + public void forceUpdate() { + mIterator.start(); + mIterator.end(); + + for (ViewHolderItem item : mPendingDownloadsItems) { + item.resetSpeedMeasure(); + } + + 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) + clearButton.setVisible(mIterator.hasFinishedMissions()); + + mClear = clearButton; + } + + public void setMasterButtons(MenuItem startButton, MenuItem pauseButton) { + boolean init = mStartButton == null || mPauseButton == null; + + mStartButton = startButton; + mPauseButton = pauseButton; + + if (init) checkMasterButtonsVisibility(); + } + + private void checkEmptyMessageVisibility() { + int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; + if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); + } + + public void checkMasterButtonsVisibility() { + boolean[] state = mIterator.hasValidPendingMissions(); + Log.d(TAG, "checkMasterButtonsVisibility() running=" + state[0] + " paused=" + state[1]); + setButtonVisible(mPauseButton, state[0]); + setButtonVisible(mStartButton, state[1]); + } + + private static void setButtonVisible(MenuItem button, boolean visible) { + if (button.isVisible() != visible) + button.setVisible(visible); + } + + public void refreshMissionItems() { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (((DownloadMission) h.item.mission).running) continue; + updateProgress(h); + h.resetSpeedMeasure(); + } + } + + + public void onDestroy() { + mDeleter.dispose(); + } + + public void onResume() { + mDeleter.resume(); + mHandler.post(rUpdater); + } + + public void onPaused() { + mDeleter.pause(); + mHandler.removeCallbacks(rUpdater); + } + + + public void recoverMission(DownloadMission mission) { + ViewHolderItem h = getViewHolder(mission); + if (h == null) return; + + mission.errObject = null; + mission.resetState(true, false, DownloadMission.ERROR_NOTHING); + + h.status.setText(UNDEFINED_PROGRESS); + h.size.setText(Utility.formatBytes(mission.getLength())); + h.progress.setMarquee(true); + + mDownloadManager.resumeMission(mission); + } + + private void updater() { + for (ViewHolderItem h : mPendingDownloadsItems) { + // check if the mission is running first + if (!((DownloadMission) h.item.mission).running) continue; + + updateProgress(h); + } + + mHandler.postDelayed(rUpdater, 1000); + } + + private boolean isNotFinite(double value) { + return Double.isNaN(value) || Double.isInfinite(value); + } + + public void setRecover(@NonNull RecoverHelper callback) { + mRecover = callback; + } + + + class ViewHolderItem extends RecyclerView.ViewHolder { + DownloadManager.MissionItem item; + + TextView status; + ImageView icon; + TextView name; + TextView size; + ProgressDrawable progress; + + PopupMenu popupMenu; + MenuItem retry; + MenuItem cancel; + MenuItem start; + MenuItem pause; + MenuItem open; + MenuItem queue; + MenuItem showError; + MenuItem delete; + MenuItem source; + MenuItem checksum; + + long lastTimestamp = -1; + double lastDone; + int lastSpeedIdx; + float[] lastSpeed = new float[3]; + String estimatedTimeArrival = UNDEFINED_ETA; + + 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(); + retry = menu.findItem(R.id.retry); + cancel = menu.findItem(R.id.cancel); + start = menu.findItem(R.id.start); + pause = menu.findItem(R.id.pause); + open = menu.findItem(R.id.menu_item_share); + 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.setHapticFeedbackEnabled(true); + + itemView.setOnClickListener(v -> { + if (item.mission instanceof FinishedMission) + viewWithFileProvider(item.mission); + }); + + itemView.setOnLongClickListener(v -> { + v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); + showPopupMenu(); + return true; + }); + } + + private void showPopupMenu() { + retry.setVisible(false); + cancel.setVisible(false); + 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.hasInvalidStorage()) { + retry.setVisible(true); + delete.setVisible(true); + showError.setVisible(true); + } else if (mission.isPsRunning()) { + switch (mission.errCode) { + case ERROR_INSUFFICIENT_STORAGE: + case ERROR_POSTPROCESSING_HOLD: + retry.setVisible(true); + cancel.setVisible(true); + showError.setVisible(true); + break; + } + } else { + if (mission.running) { + pause.setVisible(true); + } else { + if (mission.errCode != ERROR_NOTHING) { + showError.setVisible(true); + } + + queue.setChecked(mission.enqueued); + + delete.setVisible(true); + + boolean flag = !mission.isPsFailed() && mission.urls.length > 0; + start.setVisible(flag); + queue.setVisible(flag); + } + } + } 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; + } + + private void resetSpeedMeasure() { + estimatedTimeArrival = UNDEFINED_ETA; + lastTimestamp = -1; + lastSpeedIdx = -1; + } + } + + 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 + protected void onPreExecute() { + super.onPreExecute(); + + Activity activity = getActivity(); + if (activity != null) { + // Create dialog + progressDialog = new ProgressDialog(activity); + progressDialog.setCancelable(false); + progressDialog.setMessage(activity.getString(R.string.msg_wait)); + progressDialog.show(); + } + } + + @Override + protected String doInBackground(Object... params) { + return Utility.checksum((StoredFileHelper) params[0], (String) params[1]); + } + + @Override + protected void onPostExecute(String result) { + super.onPostExecute(result); + + if (progressDialog != null) { + Utility.copyToClipboard(progressDialog.getContext(), result); + if (getActivity() != null) { + progressDialog.dismiss(); + } + } + } + + @Nullable + private Activity getActivity() { + Activity activity = weakReference.get(); + + if (activity != null && activity.isFinishing()) { + return null; + } else { + return activity; + } + } + } + + public interface RecoverHelper { + void tryRecover(DownloadMission mission); + } + +} diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java new file mode 100644 index 000000000..a92efa02e --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java @@ -0,0 +1,138 @@ +package us.shandian.giga.ui.common; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.os.Handler; +import android.view.View; + +import com.google.android.material.snackbar.Snackbar; + +import org.schabi.newpipelegacy.R; + +import java.util.ArrayList; + +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.service.DownloadManager; +import us.shandian.giga.service.DownloadManager.MissionIterator; +import us.shandian.giga.ui.adapter.MissionAdapter; + +public class Deleter { + private static final int TIMEOUT = 5000;// ms + private static final int DELAY = 350;// ms + private static final int DELAY_RESUME = 400;// ms + + private Snackbar snackbar; + private ArrayList 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(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); + } + + 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).storage.getName()); + + 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, mission.storage.getUri())); + } + break; + } + + if (items.size() < 1) { + pause(); + return; + } + + show(); + } + + public 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() { + if (items.size() < 1) return; + + pause(); + + for (Mission mission : items) mDownloadManager.deleteMission(mission); + items = null; + } +} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java new file mode 100644 index 000000000..3f638d418 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java @@ -0,0 +1,132 @@ +package us.shandian.giga.ui.common; + +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.os.Handler; +import android.os.Looper; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; + +public class ProgressDrawable extends Drawable { + private static final int MARQUEE_INTERVAL = 150; + + 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 void setColors(@ColorInt int background, @ColorInt int foreground) { + mBackgroundColor = background; + mForegroundColor = foreground; + } + + public void setProgress(double progress) { + mProgress = (float) progress; + 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 = getBounds().width(); + int height = getBounds().height(); + + Paint paint = new Paint(); + + paint.setColor(mBackgroundColor); + 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); + } + + @Override + public void setAlpha(int alpha) { + // Unsupported + } + + @Override + public void setColorFilter(ColorFilter filter) { + // Unsupported + } + + @Override + public int getOpacity() { + 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/common/ToolbarActivity.java b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java new file mode 100644 index 000000000..776683ffe --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java @@ -0,0 +1,24 @@ +package us.shandian.giga.ui.common; + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import org.schabi.newpipelegacy.R; + +public abstract class ToolbarActivity extends AppCompatActivity { + protected Toolbar mToolbar; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(getLayoutResource()); + + mToolbar = this.findViewById(R.id.toolbar); + + setSupportActionBar(mToolbar); + } + + protected abstract int getLayoutResource(); +} 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 new file mode 100644 index 000000000..3d3685960 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -0,0 +1,322 @@ +package us.shandian.giga.ui.fragment; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.settings.NewPipeSettings; +import org.schabi.newpipelegacy.util.FilePickerActivityHelper; +import org.schabi.newpipelegacy.util.ThemeHelper; + +import java.io.File; +import java.io.IOException; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.io.StoredFileHelper; +import us.shandian.giga.service.DownloadManager; +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; +import us.shandian.giga.ui.adapter.MissionAdapter; + +public class MissionsFragment extends Fragment { + + private static final int SPAN_SIZE = 2; + private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230; + + private SharedPreferences mPrefs; + private boolean mLinear; + private MenuItem mSwitch; + private MenuItem mClear = null; + private MenuItem mStart = null; + private MenuItem mPause = null; + + private RecyclerView mList; + private View mEmpty; + private MissionAdapter mAdapter; + private GridLayoutManager mGridManager; + private LinearLayoutManager mLinearManager; + private Context mContext; + + private DownloadManagerBinder mBinder; + private boolean mForceUpdate; + + private DownloadMission unsafeMissionTarget = null; + + private ServiceConnection mConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName name, IBinder binder) { + mBinder = (DownloadManagerBinder) binder; + mBinder.clearDownloadNotifications(); + + mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty, getView()); + + mAdapter.setRecover(MissionsFragment.this::recoverMission); + + setAdapterButtons(); + + mBinder.addMissionEventListener(mAdapter); + mBinder.enableNotifications(false); + + updateList(); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + // What to do? + } + + + }; + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View v = inflater.inflate(R.layout.missions, container, false); + + mPrefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + mLinear = mPrefs.getBoolean("linear", false); + + // Bind the service + mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); + + // Views + mEmpty = v.findViewById(R.id.list_empty_view); + mList = v.findViewById(R.id.mission_recycler); + + // Init layouts managers + 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()); + + setHasOptionsMenu(true); + + return v; + } + + /** + * Added in API level 23. + */ + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + // Bug: in api< 23 this is never called + // so mActivity=null + // so app crashes with null-pointer exception + mContext = context; + } + + /** + * deprecated in API level 23, + * but must remain to allow compatibility with api<23 + */ + @SuppressWarnings("deprecation") + @Override + public void onAttach(@NonNull Activity activity) { + super.onAttach(activity); + + mContext = activity; + } + + + @Override + public void onDestroy() { + super.onDestroy(); + if (mBinder == null || mAdapter == null) return; + + mBinder.removeMissionEventListener(mAdapter); + mBinder.enableNotifications(true); + mContext.unbindService(mConnection); + mAdapter.onDestroy(); + + mBinder = null; + mAdapter = null; + } + + @Override + public void onPrepareOptionsMenu(Menu menu) { + mSwitch = menu.findItem(R.id.switch_mode); + mClear = menu.findItem(R.id.clear_list); + mStart = menu.findItem(R.id.start_downloads); + mPause = menu.findItem(R.id.pause_downloads); + + if (mAdapter != null) setAdapterButtons(); + + super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.switch_mode: + mLinear = !mLinear; + updateList(); + return true; + case R.id.clear_list: + AlertDialog.Builder prompt = new AlertDialog.Builder(mContext); + prompt.setTitle(R.string.clear_download_history); + prompt.setMessage(R.string.confirm_prompt); + // Intentionally misusing button's purpose in order to achieve good order + prompt.setNegativeButton(R.string.clear_download_history, (dialog, which) -> mAdapter.clearFinishedDownloads(false)); + prompt.setPositiveButton(R.string.delete_downloaded_files, (dialog, which) -> mAdapter.clearFinishedDownloads(true)); + prompt.setNeutralButton(R.string.cancel, null); + prompt.create().show(); + return true; + case R.id.start_downloads: + mBinder.getDownloadManager().startAllMissions(); + return true; + case R.id.pause_downloads: + mBinder.getDownloadManager().pauseAllMissions(false); + mAdapter.refreshMissionItems();// update items view + default: + return super.onOptionsItemSelected(item); + } + } + + private void updateList() { + 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 + ? ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_grid) + : ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_list)); + mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); + mPrefs.edit().putBoolean("linear", mLinear).apply(); + } + } + + private void setAdapterButtons() { + if (mClear == null || mStart == null || mPause == null) return; + + mAdapter.setClearButton(mClear); + mAdapter.setMasterButtons(mStart, mPause); + } + + private void recoverMission(@NonNull DownloadMission mission) { + unsafeMissionTarget = mission; + + if (NewPipeSettings.useStorageAccessFramework(mContext)) { + StoredFileHelper.requestSafWithFileCreation( + MissionsFragment.this, + REQUEST_DOWNLOAD_SAVE_AS, + mission.storage.getName(), + mission.storage.getType() + ); + + } else { + File initialSavePath; + if (DownloadManager.TAG_VIDEO.equals(mission.storage.getType())) + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + else + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); + + initialSavePath = new File(initialSavePath, mission.storage.getName()); + startActivityForResult( + FilePickerActivityHelper.chooseFileToSave(mContext, initialSavePath.getAbsolutePath()), + REQUEST_DOWNLOAD_SAVE_AS + ); + } + } + + @Override + public void onResume() { + super.onResume(); + + if (mAdapter != null) { + mAdapter.onResume(); + + if (mForceUpdate) { + mForceUpdate = false; + mAdapter.forceUpdate(); + } + + mBinder.addMissionEventListener(mAdapter); + mAdapter.checkMasterButtonsVisibility(); + } + if (mBinder != null) mBinder.enableNotifications(false); + } + + @Override + public void onPause() { + super.onPause(); + + if (mAdapter != null) { + mForceUpdate = true; + mBinder.removeMissionEventListener(mAdapter); + mAdapter.onPaused(); + } + + if (mBinder != null) mBinder.enableNotifications(true); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + if (requestCode != REQUEST_DOWNLOAD_SAVE_AS || resultCode != Activity.RESULT_OK) return; + + if (unsafeMissionTarget == null || data.getData() == null) { + return; + } + + try { + Uri fileUri = data.getData(); + if (fileUri.getAuthority() != null && FilePickerActivityHelper.isOwnFileUri(mContext, fileUri)) { + fileUri = Uri.fromFile(Utils.getFileForUri(fileUri)); + } + + String tag = unsafeMissionTarget.storage.getTag(); + unsafeMissionTarget.storage = new StoredFileHelper(mContext, null, fileUri, tag); + mAdapter.recoverMission(unsafeMissionTarget); + } catch (IOException e) { + Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show(); + } + } +} diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java new file mode 100644 index 000000000..69f64b2a8 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -0,0 +1,322 @@ +package us.shandian.giga.util; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.widget.Toast; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipelegacy.R; +import org.schabi.newpipelegacy.streams.io.SharpStream; + +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +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.List; +import java.util.Locale; + +import us.shandian.giga.io.StoredFileHelper; + +public class Utility { + + public enum FileType { + VIDEO, + MUSIC, + SUBTITLE, + UNKNOWN + } + + public static String formatBytes(long bytes) { + Locale locale = Locale.getDefault(); + if (bytes < 1024) { + return String.format(locale, "%d B", bytes); + } else if (bytes < 1024 * 1024) { + return String.format(locale, "%.2f kB", bytes / 1024d); + } else if (bytes < 1024 * 1024 * 1024) { + return String.format(locale, "%.2f MB", bytes / 1024d / 1024d); + } else { + return String.format(locale, "%.2f GB", bytes / 1024d / 1024d / 1024d); + } + } + + public static String formatSpeed(double speed) { + Locale locale = Locale.getDefault(); + if (speed < 1024) { + return String.format(locale, "%.2f B/s", speed); + } else if (speed < 1024 * 1024) { + return String.format(locale, "%.2f kB/s", speed / 1024); + } else if (speed < 1024 * 1024 * 1024) { + return String.format(locale, "%.2f MB/s", speed / 1024 / 1024); + } else { + return String.format(locale, "%.2f GB/s", speed / 1024 / 1024 / 1024); + } + } + + public static void writeToFile(@NonNull File file, @NonNull Serializable serializable) { + + try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { + objectOutputStream.writeObject(serializable); + } catch (Exception e) { + //nothing to do + } + //nothing to do + } + + @Nullable + @SuppressWarnings("unchecked") + 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) { + Log.e("Utility", "Failed to deserialize the object", e); + object = null; + } + + if (objectInputStream != null) { + try { + objectInputStream.close(); + } catch (Exception e) { + //nothing to do + } + } + + return object; + } + + @Nullable + public static String getFileExt(String url) { + int index; + if ((index = url.indexOf("?")) > -1) { + url = url.substring(0, index); + } + + index = url.lastIndexOf("."); + if (index == -1) { + return null; + } else { + String ext = url.substring(index); + if ((index = ext.indexOf("%")) > -1) { + ext = ext.substring(0, index); + } + if ((index = ext.indexOf("/")) > -1) { + ext = ext.substring(0, index); + } + return ext.toLowerCase(); + } + } + + 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; + } + + return FileType.UNKNOWN; + } + + @ColorInt + public static int getBackgroundForFileType(Context ctx, FileType type) { + int colorRes; + switch (type) { + case MUSIC: + colorRes = R.color.audio_left_to_load_color; + break; + case VIDEO: + colorRes = R.color.video_left_to_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_left_to_load_color; + break; + default: + colorRes = R.color.gray; + } + + return ContextCompat.getColor(ctx, colorRes); + } + + @ColorInt + public static int getForegroundForFileType(Context ctx, FileType type) { + int colorRes; + switch (type) { + case MUSIC: + colorRes = R.color.audio_already_load_color; + break; + case VIDEO: + colorRes = R.color.video_already_load_color; + break; + case SUBTITLE: + colorRes = R.color.subtitle_already_load_color; + break; + default: + colorRes = R.color.gray; + break; + } + + return ContextCompat.getColor(ctx, colorRes); + } + + @DrawableRes + public static int getIconForFileType(FileType type) { + switch (type) { + case MUSIC: + return R.drawable.ic_headset_white_24dp; + default: + case VIDEO: + return R.drawable.ic_movie_white_24dp; + case SUBTITLE: + return R.drawable.ic_subtitles_white_24dp; + } + } + + 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(StoredFileHelper source, String algorithm) { + MessageDigest md; + + try { + md = MessageDigest.getInstance(algorithm); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + + SharpStream i; + + try { + i = source.getStream(); + } catch (Exception e) { + throw new RuntimeException(e); + } + + byte[] buf = new byte[1024]; + int len; + + try { + while ((len = i.read(buf)) != -1) { + md.update(buf, 0, len); + } + } catch (IOException e) { + // nothing to do + } + + byte[] digest = md.digest(); + + // HEX + StringBuilder sb = new StringBuilder(); + for (byte b : digest) { + sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1)); + } + + return sb.toString(); + + } + + @SuppressWarnings("ResultOfMethodCallIgnored") + public static boolean mkdir(File p, boolean allDirs) { + if (p.exists()) return true; + + if (allDirs) + p.mkdirs(); + else + p.mkdir(); + + return p.exists(); + } + + public static long getContentLength(HttpURLConnection connection) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return connection.getContentLengthLong(); + } + + try { + return Long.parseLong(connection.getHeaderField("Content-Length")); + } catch (Exception err) { + // nothing to do + } + + return -1; + } + + private static String pad(int number) { + return number < 10 ? ("0" + number) : String.valueOf(number); + } + + public static String stringifySeconds(double seconds) { + int h = (int) Math.floor(seconds / 3600); + int m = (int) Math.floor((seconds - (h * 3600)) / 60); + int s = (int) (seconds - (h * 3600) - (m * 60)); + + String str = ""; + + if (h < 1 && m < 1) { + str = "00:"; + } else { + if (h > 0) str = pad(h) + ":"; + if (m > 0) str += pad(m) + ":"; + } + + return str + pad(s); + } + + static final private String PATH_DOCUMENT = "document"; + static final private String PATH_TREE = "tree"; + + public static String getDocumentId(Uri documentUri) { + final List paths = documentUri.getPathSegments(); + if (paths.size() >= 2 && PATH_DOCUMENT.equals(paths.get(0))) { + return paths.get(1); + } + if (paths.size() >= 4 && PATH_TREE.equals(paths.get(0)) + && PATH_DOCUMENT.equals(paths.get(2))) { + return paths.get(3); + } + throw new IllegalArgumentException("Invalid URI: " + documentUri); + } + + public static boolean equalsIgnoreCase(String left, String right) { + return left != null && left.equalsIgnoreCase(right); + } +} diff --git a/app/src/main/res/anim/switch_service_in.xml b/app/src/main/res/anim/switch_service_in.xml new file mode 100644 index 000000000..a49d1daba --- /dev/null +++ b/app/src/main/res/anim/switch_service_in.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/anim/switch_service_out.xml b/app/src/main/res/anim/switch_service_out.xml new file mode 100644 index 000000000..635d1630e --- /dev/null +++ b/app/src/main/res/anim/switch_service_out.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/animator/custom_fade_in.xml b/app/src/main/res/animator/custom_fade_in.xml new file mode 100644 index 000000000..fa7f516c2 --- /dev/null +++ b/app/src/main/res/animator/custom_fade_in.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/animator/custom_fade_out.xml b/app/src/main/res/animator/custom_fade_out.xml new file mode 100644 index 000000000..db3662647 --- /dev/null +++ b/app/src/main/res/animator/custom_fade_out.xml @@ -0,0 +1,9 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png new file mode 100644 index 000000000..ceb1a1eeb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png new file mode 100644 index 000000000..cb26a5f65 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_newpipe_update.png b/app/src/main/res/drawable-hdpi/ic_newpipe_update.png new file mode 100644 index 000000000..cbf336a1f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_newpipe_update.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_replay_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_replay_white_24dp.png new file mode 100644 index 000000000..fcddcf02d Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_replay_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png new file mode 100644 index 000000000..af7f8288d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png new file mode 100644 index 000000000..fc86823ac Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_newpipe_update.png b/app/src/main/res/drawable-mdpi/ic_newpipe_update.png new file mode 100644 index 000000000..8ab23eb6a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_newpipe_update.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_replay_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_replay_white_24dp.png new file mode 100644 index 000000000..3b4191325 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_replay_white_24dp.png differ diff --git a/app/src/main/res/drawable-nodpi/background_header.png b/app/src/main/res/drawable-nodpi/background_header.png new file mode 100644 index 000000000..b417038f6 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/background_header.png differ diff --git a/app/src/main/res/drawable-nodpi/buddy.png b/app/src/main/res/drawable-nodpi/buddy.png new file mode 100644 index 000000000..1b414ffa7 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/buddy.png differ diff --git a/app/src/main/res/drawable-nodpi/buddy_channel_item.png b/app/src/main/res/drawable-nodpi/buddy_channel_item.png new file mode 100644 index 000000000..d43c2cbd9 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/buddy_channel_item.png differ diff --git a/app/src/main/res/drawable-nodpi/channel_banner.png b/app/src/main/res/drawable-nodpi/channel_banner.png new file mode 100644 index 000000000..94b25e98b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/channel_banner.png differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail.png new file mode 100644 index 000000000..24230b261 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/dummy_thumbnail.png differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png new file mode 100644 index 000000000..7123a6f01 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png new file mode 100644 index 000000000..c70e4bf14 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png differ diff --git a/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png b/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png new file mode 100644 index 000000000..99b91d374 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png differ diff --git a/app/src/main/res/drawable-nodpi/not_available_monkey.png b/app/src/main/res/drawable-nodpi/not_available_monkey.png new file mode 100644 index 000000000..ef0068bed Binary files /dev/null and b/app/src/main/res/drawable-nodpi/not_available_monkey.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_circle.png b/app/src/main/res/drawable-nodpi/place_holder_circle.png new file mode 100644 index 000000000..704729e8f Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_circle.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_cloud.png b/app/src/main/res/drawable-nodpi/place_holder_cloud.png new file mode 100644 index 000000000..f78e846e1 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_cloud.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_gadse.png b/app/src/main/res/drawable-nodpi/place_holder_gadse.png new file mode 100644 index 000000000..f11dd57e5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_gadse.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_peertube.png b/app/src/main/res/drawable-nodpi/place_holder_peertube.png new file mode 100644 index 000000000..68850054d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_peertube.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_youtube.png b/app/src/main/res/drawable-nodpi/place_holder_youtube.png new file mode 100644 index 000000000..c4113e005 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_youtube.png differ diff --git a/app/src/main/res/drawable-v23/splash_background.xml b/app/src/main/res/drawable-v23/splash_background.xml new file mode 100644 index 000000000..a11787c8a --- /dev/null +++ b/app/src/main/res/drawable-v23/splash_background.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png new file mode 100644 index 000000000..b7c7ffd0e Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png new file mode 100644 index 000000000..b90c55050 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png new file mode 100644 index 000000000..5ee02aaa9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp.png new file mode 100644 index 000000000..1573fb111 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png new file mode 100644 index 000000000..6b717e0dd Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png new file mode 100644 index 000000000..acde4439e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png new file mode 100644 index 000000000..22f0e99d1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp.png new file mode 100644 index 000000000..5105c2251 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png new file mode 100644 index 000000000..396419219 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png new file mode 100644 index 000000000..93cfda12d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.png b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.png new file mode 100644 index 000000000..1f44c1aaf Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp.png new file mode 100644 index 000000000..04cbde9af Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp.png differ diff --git a/app/src/main/res/drawable/background_oval_black_transparent.xml b/app/src/main/res/drawable/background_oval_black_transparent.xml new file mode 100644 index 000000000..5db5969c6 --- /dev/null +++ b/app/src/main/res/drawable/background_oval_black_transparent.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/custom_progress_bar.xml b/app/src/main/res/drawable/custom_progress_bar.xml new file mode 100644 index 000000000..251cde171 --- /dev/null +++ b/app/src/main/res/drawable/custom_progress_bar.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_black.xml b/app/src/main/res/drawable/dashed_border_black.xml new file mode 100644 index 000000000..b6bac6252 --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_black.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_dark.xml b/app/src/main/res/drawable/dashed_border_dark.xml new file mode 100644 index 000000000..5af152ecc --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_dark.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dashed_border_light.xml b/app/src/main/res/drawable/dashed_border_light.xml new file mode 100644 index 000000000..5d29112bd --- /dev/null +++ b/app/src/main/res/drawable/dashed_border_light.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_default.xml b/app/src/main/res/drawable/dot_default.xml new file mode 100644 index 000000000..3380dca3b --- /dev/null +++ b/app/src/main/res/drawable/dot_default.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/dot_selected.xml b/app/src/main/res/drawable/dot_selected.xml new file mode 100644 index 000000000..017e99d43 --- /dev/null +++ b/app/src/main/res/drawable/dot_selected.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/drawer_header_bottom_background.xml b/app/src/main/res/drawable/drawer_header_bottom_background.xml new file mode 100644 index 000000000..913522274 --- /dev/null +++ b/app/src/main/res/drawable/drawer_header_bottom_background.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_add_black_24dp.xml b/app/src/main/res/drawable/ic_add_black_24dp.xml new file mode 100644 index 000000000..0258249cc --- /dev/null +++ b/app/src/main/res/drawable/ic_add_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml b/app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml new file mode 100644 index 000000000..900f2275e --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_circle_outline_white_24dp.xml b/app/src/main/res/drawable/ic_add_circle_outline_white_24dp.xml new file mode 100644 index 000000000..66d3247ae --- /dev/null +++ b/app/src/main/res/drawable/ic_add_circle_outline_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable/ic_add_white_24dp.xml new file mode 100644 index 000000000..e3979cd7f --- /dev/null +++ b/app/src/main/res/drawable/ic_add_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_apps_black_24dp.xml b/app/src/main/res/drawable/ic_apps_black_24dp.xml new file mode 100644 index 000000000..ff485cf1a --- /dev/null +++ b/app/src/main/res/drawable/ic_apps_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_apps_white_24dp.xml b/app/src/main/res/drawable/ic_apps_white_24dp.xml new file mode 100644 index 000000000..373f7752b --- /dev/null +++ b/app/src/main/res/drawable/ic_apps_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml new file mode 100644 index 000000000..beafea395 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml new file mode 100644 index 000000000..71d5bbd29 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_down_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_down_white_24dp.xml new file mode 100644 index 000000000..65e1e4228 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_down_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_drop_up_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_up_white_24dp.xml new file mode 100644 index 000000000..1d266cecc --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_drop_up_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml new file mode 100644 index 000000000..fa16cd5e8 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml new file mode 100644 index 000000000..bd487cb55 --- /dev/null +++ b/app/src/main/res/drawable/ic_asterisk_white_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_attach_money_black_24dp.xml b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml new file mode 100644 index 000000000..b520fc98d --- /dev/null +++ b/app/src/main/res/drawable/ic_attach_money_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_attach_money_white_24dp.xml b/app/src/main/res/drawable/ic_attach_money_white_24dp.xml new file mode 100644 index 000000000..d198dd14d --- /dev/null +++ b/app/src/main/res/drawable/ic_attach_money_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_black_24dp.xml b/app/src/main/res/drawable/ic_backup_black_24dp.xml new file mode 100644 index 000000000..086281669 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_backup_white_24dp.xml b/app/src/main/res/drawable/ic_backup_white_24dp.xml new file mode 100644 index 000000000..55dbbae85 --- /dev/null +++ b/app/src/main/res/drawable/ic_backup_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark_black_24dp.xml b/app/src/main/res/drawable/ic_bookmark_black_24dp.xml new file mode 100644 index 000000000..6a6a1b39d --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bookmark_white_24dp.xml b/app/src/main/res/drawable/ic_bookmark_white_24dp.xml new file mode 100644 index 000000000..feb16ed63 --- /dev/null +++ b/app/src/main/res/drawable/ic_bookmark_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_high_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_high_white_24dp.xml new file mode 100644 index 000000000..9ed0b086c --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_high_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_low_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_low_white_24dp.xml new file mode 100644 index 000000000..da4e0ca30 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_low_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_brightness_medium_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_medium_white_24dp.xml new file mode 100644 index 000000000..c522453f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_brightness_medium_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug_report_black_24dp.xml b/app/src/main/res/drawable/ic_bug_report_black_24dp.xml new file mode 100644 index 000000000..4d83902b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_bug_report_white_24dp.xml b/app/src/main/res/drawable/ic_bug_report_white_24dp.xml new file mode 100644 index 000000000..5c8f5bc16 --- /dev/null +++ b/app/src/main/res/drawable/ic_bug_report_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast_black_24dp.xml b/app/src/main/res/drawable/ic_cast_black_24dp.xml new file mode 100644 index 000000000..7b143de9f --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cast_white_24dp.xml b/app/src/main/res/drawable/ic_cast_white_24dp.xml new file mode 100644 index 000000000..434c64416 --- /dev/null +++ b/app/src/main/res/drawable/ic_cast_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_child_care_black_24dp.xml b/app/src/main/res/drawable/ic_child_care_black_24dp.xml new file mode 100644 index 000000000..5af39255e --- /dev/null +++ b/app/src/main/res/drawable/ic_child_care_black_24dp.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_child_care_white_24dp.xml b/app/src/main/res/drawable/ic_child_care_white_24dp.xml new file mode 100644 index 000000000..81fa2ddc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_child_care_white_24dp.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_close_black_24dp.xml b/app/src/main/res/drawable/ic_close_black_24dp.xml new file mode 100644 index 000000000..ede4b7108 --- /dev/null +++ b/app/src/main/res/drawable/ic_close_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml b/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml new file mode 100644 index 000000000..261c31217 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml b/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml new file mode 100644 index 000000000..0feb270af --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer_black_24dp.xml new file mode 100644 index 000000000..4599f98cd --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable/ic_computer_white_24dp.xml new file mode 100644 index 000000000..9569b7747 --- /dev/null +++ b/app/src/main/res/drawable/ic_computer_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_portrait_black_24dp.xml b/app/src/main/res/drawable/ic_crop_portrait_black_24dp.xml new file mode 100644 index 000000000..e8c60a1a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_portrait_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_crop_portrait_white_24dp.xml b/app/src/main/res/drawable/ic_crop_portrait_white_24dp.xml new file mode 100644 index 000000000..caba925a1 --- /dev/null +++ b/app/src/main/res/drawable/ic_crop_portrait_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_black_24dp.xml b/app/src/main/res/drawable/ic_delete_black_24dp.xml new file mode 100644 index 000000000..39e64d698 --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_delete_white_24dp.xml b/app/src/main/res/drawable/ic_delete_white_24dp.xml new file mode 100644 index 000000000..8bed121aa --- /dev/null +++ b/app/src/main/res/drawable/ic_delete_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_directions_bike_black_24dp.xml b/app/src/main/res/drawable/ic_directions_bike_black_24dp.xml new file mode 100644 index 000000000..ded5e3359 --- /dev/null +++ b/app/src/main/res/drawable/ic_directions_bike_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_directions_bike_white_24dp.xml b/app/src/main/res/drawable/ic_directions_bike_white_24dp.xml new file mode 100644 index 000000000..f165cea9c --- /dev/null +++ b/app/src/main/res/drawable/ic_directions_bike_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_directions_car_black_24dp.xml b/app/src/main/res/drawable/ic_directions_car_black_24dp.xml new file mode 100644 index 000000000..6d6337c3a --- /dev/null +++ b/app/src/main/res/drawable/ic_directions_car_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_directions_car_white_24dp.xml b/app/src/main/res/drawable/ic_directions_car_white_24dp.xml new file mode 100644 index 000000000..981334c17 --- /dev/null +++ b/app/src/main/res/drawable/ic_directions_car_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_black_24dp.xml b/app/src/main/res/drawable/ic_done_black_24dp.xml new file mode 100644 index 000000000..7affe9ba9 --- /dev/null +++ b/app/src/main/res/drawable/ic_done_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_done_white_24dp.xml b/app/src/main/res/drawable/ic_done_white_24dp.xml new file mode 100644 index 000000000..cab2aed1a --- /dev/null +++ b/app/src/main/res/drawable/ic_done_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_handle_black_24dp.xml b/app/src/main/res/drawable/ic_drag_handle_black_24dp.xml new file mode 100644 index 000000000..68a719052 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_drag_handle_white_24dp.xml b/app/src/main/res/drawable/ic_drag_handle_white_24dp.xml new file mode 100644 index 000000000..50f9e6c29 --- /dev/null +++ b/app/src/main/res/drawable/ic_drag_handle_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit_black_24dp.xml new file mode 100644 index 000000000..43489826e --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable/ic_edit_white_24dp.xml new file mode 100644 index 000000000..88f94780f --- /dev/null +++ b/app/src/main/res/drawable/ic_edit_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less_black_24dp.xml b/app/src/main/res/drawable/ic_expand_less_black_24dp.xml new file mode 100644 index 000000000..3afdf9682 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_less_white_24dp.xml b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml new file mode 100644 index 000000000..5042d801a --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_black_24dp.xml b/app/src/main/res/drawable/ic_expand_more_black_24dp.xml new file mode 100644 index 000000000..8d57dbc10 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_more_white_24dp.xml b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml new file mode 100644 index 000000000..bc72bdce0 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_more_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore_black_24dp.xml new file mode 100644 index 000000000..c898ed9a5 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable/ic_explore_white_24dp.xml new file mode 100644 index 000000000..65f2818a6 --- /dev/null +++ b/app/src/main/res/drawable/ic_explore_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml b/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml new file mode 100644 index 000000000..da7c3fb1e --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_rewind_white_24dp.xml b/app/src/main/res/drawable/ic_fast_rewind_white_24dp.xml new file mode 100644 index 000000000..4bab93ecb --- /dev/null +++ b/app/src/main/res/drawable/ic_fast_rewind_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml new file mode 100644 index 000000000..4de2eb9af --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml new file mode 100644 index 000000000..517b92573 --- /dev/null +++ b/app/src/main/res/drawable/ic_fastfood_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_black_24dp.xml b/app/src/main/res/drawable/ic_favorite_black_24dp.xml new file mode 100644 index 000000000..cfba5d846 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_favorite_white_24dp.xml b/app/src/main/res/drawable/ic_favorite_white_24dp.xml new file mode 100644 index 000000000..67a25e713 --- /dev/null +++ b/app/src/main/res/drawable/ic_favorite_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_black_24dp.xml b/app/src/main/res/drawable/ic_file_download_black_24dp.xml new file mode 100644 index 000000000..492b41d34 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_file_download_white_24dp.xml b/app/src/main/res/drawable/ic_file_download_white_24dp.xml new file mode 100644 index 000000000..b8e836142 --- /dev/null +++ b/app/src/main/res/drawable/ic_file_download_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_list_black_24dp.xml b/app/src/main/res/drawable/ic_filter_list_black_24dp.xml new file mode 100644 index 000000000..b99b672f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter_list_white_24dp.xml b/app/src/main/res/drawable/ic_filter_list_white_24dp.xml new file mode 100644 index 000000000..5d4ec18ee --- /dev/null +++ b/app/src/main/res/drawable/ic_filter_list_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_center_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_center_black_24dp.xml new file mode 100644 index 000000000..846deb431 --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_center_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_fitness_center_white_24dp.xml b/app/src/main/res/drawable/ic_fitness_center_white_24dp.xml new file mode 100644 index 000000000..fec3c955c --- /dev/null +++ b/app/src/main/res/drawable/ic_fitness_center_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_fullscreen_exit_white_24dp.xml b/app/src/main/res/drawable/ic_fullscreen_exit_white_24dp.xml new file mode 100644 index 000000000..bb7140f29 --- /dev/null +++ b/app/src/main/res/drawable/ic_fullscreen_exit_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_fullscreen_white_24dp.xml b/app/src/main/res/drawable/ic_fullscreen_white_24dp.xml new file mode 100644 index 000000000..86b7649b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_fullscreen_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_headset_black_24dp.xml b/app/src/main/res/drawable/ic_headset_black_24dp.xml new file mode 100644 index 000000000..d4503ce60 --- /dev/null +++ b/app/src/main/res/drawable/ic_headset_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_headset_shadow.xml b/app/src/main/res/drawable/ic_headset_shadow.xml new file mode 100644 index 000000000..53a3ec31a --- /dev/null +++ b/app/src/main/res/drawable/ic_headset_shadow.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_headset_white_24dp.xml b/app/src/main/res/drawable/ic_headset_white_24dp.xml new file mode 100644 index 000000000..2027245b3 --- /dev/null +++ b/app/src/main/res/drawable/ic_headset_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help_black_24dp.xml new file mode 100644 index 000000000..1517747d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable/ic_help_white_24dp.xml new file mode 100644 index 000000000..d813b72b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_help_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_black_24dp.xml b/app/src/main/res/drawable/ic_history_black_24dp.xml new file mode 100644 index 000000000..a61de1bc9 --- /dev/null +++ b/app/src/main/res/drawable/ic_history_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_history_white_24dp.xml b/app/src/main/res/drawable/ic_history_white_24dp.xml new file mode 100644 index 000000000..de25eb445 --- /dev/null +++ b/app/src/main/res/drawable/ic_history_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_black_24dp.xml b/app/src/main/res/drawable/ic_home_black_24dp.xml new file mode 100644 index 000000000..70fb2910c --- /dev/null +++ b/app/src/main/res/drawable/ic_home_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_home_white_24dp.xml b/app/src/main/res/drawable/ic_home_white_24dp.xml new file mode 100644 index 000000000..30296ba99 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_import_export_black_24dp.xml b/app/src/main/res/drawable/ic_import_export_black_24dp.xml new file mode 100644 index 000000000..a2d1fa99f --- /dev/null +++ b/app/src/main/res/drawable/ic_import_export_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_import_export_white_24dp.xml b/app/src/main/res/drawable/ic_import_export_white_24dp.xml new file mode 100644 index 000000000..4c6fc6ef6 --- /dev/null +++ b/app/src/main/res/drawable/ic_import_export_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_black_24dp.xml b/app/src/main/res/drawable/ic_info_outline_black_24dp.xml new file mode 100644 index 000000000..cf53e145c --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_info_outline_white_24dp.xml b/app/src/main/res/drawable/ic_info_outline_white_24dp.xml new file mode 100644 index 000000000..af0d4d067 --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outline_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_insert_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_insert_emoticon_black_24dp.xml new file mode 100644 index 000000000..43d5552cd --- /dev/null +++ b/app/src/main/res/drawable/ic_insert_emoticon_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_insert_emoticon_white_24dp.xml b/app/src/main/res/drawable/ic_insert_emoticon_white_24dp.xml new file mode 100644 index 000000000..a438c34ef --- /dev/null +++ b/app/src/main/res/drawable/ic_insert_emoticon_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_language_black_24dp.xml b/app/src/main/res/drawable/ic_language_black_24dp.xml new file mode 100644 index 000000000..d07324c87 --- /dev/null +++ b/app/src/main/res/drawable/ic_language_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_language_white_24dp.xml b/app/src/main/res/drawable/ic_language_white_24dp.xml new file mode 100644 index 000000000..74bc27903 --- /dev/null +++ b/app/src/main/res/drawable/ic_language_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_list_black_24dp.xml b/app/src/main/res/drawable/ic_list_black_24dp.xml new file mode 100644 index 000000000..4c2fb8834 --- /dev/null +++ b/app/src/main/res/drawable/ic_list_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_list_white_24dp.xml b/app/src/main/res/drawable/ic_list_white_24dp.xml new file mode 100644 index 000000000..f47037629 --- /dev/null +++ b/app/src/main/res/drawable/ic_list_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml new file mode 100644 index 000000000..21622c162 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml new file mode 100644 index 000000000..90e6ff215 --- /dev/null +++ b/app/src/main/res/drawable/ic_megaphone_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic_black_24dp.xml new file mode 100644 index 000000000..25d8951a7 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable/ic_mic_white_24dp.xml new file mode 100644 index 000000000..36ee9ff81 --- /dev/null +++ b/app/src/main/res/drawable/ic_mic_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_black_24dp.xml b/app/src/main/res/drawable/ic_more_vert_black_24dp.xml new file mode 100644 index 000000000..5176d8a4b --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_vert_white_24dp.xml b/app/src/main/res/drawable/ic_more_vert_white_24dp.xml new file mode 100644 index 000000000..c097d3e40 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_vert_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml new file mode 100644 index 000000000..539182f83 --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml new file mode 100644 index 000000000..d5f2519d7 --- /dev/null +++ b/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie_black_24dp.xml new file mode 100644 index 000000000..d70c00f00 --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable/ic_movie_white_24dp.xml new file mode 100644 index 000000000..a1d539a3f --- /dev/null +++ b/app/src/main/res/drawable/ic_movie_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note_black_24dp.xml new file mode 100644 index 000000000..736c004ef --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable/ic_music_note_white_24dp.xml new file mode 100644 index 000000000..69f0a3a4d --- /dev/null +++ b/app/src/main/res/drawable/ic_music_note_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette_black_24dp.xml b/app/src/main/res/drawable/ic_palette_black_24dp.xml new file mode 100644 index 000000000..f75e2fbe3 --- /dev/null +++ b/app/src/main/res/drawable/ic_palette_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_palette_white_24dp.xml b/app/src/main/res/drawable/ic_palette_white_24dp.xml new file mode 100644 index 000000000..4abeea58f --- /dev/null +++ b/app/src/main/res/drawable/ic_palette_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_black_24dp.xml b/app/src/main/res/drawable/ic_pause_black_24dp.xml new file mode 100644 index 000000000..bb28a6c41 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_pause_white_24dp.xml b/app/src/main/res/drawable/ic_pause_white_24dp.xml new file mode 100644 index 000000000..08b34c2da --- /dev/null +++ b/app/src/main/res/drawable/ic_pause_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people_black_24dp.xml new file mode 100644 index 000000000..4cfd86960 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable/ic_people_white_24dp.xml new file mode 100644 index 000000000..23afe2270 --- /dev/null +++ b/app/src/main/res/drawable/ic_people_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person_black_24dp.xml new file mode 100644 index 000000000..b2cb337b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable/ic_person_white_24dp.xml new file mode 100644 index 000000000..d7366bda0 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets_black_24dp.xml new file mode 100644 index 000000000..b6247bd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_black_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable/ic_pets_white_24dp.xml new file mode 100644 index 000000000..46724a33d --- /dev/null +++ b/app/src/main/res/drawable/ic_pets_white_24dp.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_picture_in_picture_black_24dp.xml b/app/src/main/res/drawable/ic_picture_in_picture_black_24dp.xml new file mode 100644 index 000000000..b61c5218b --- /dev/null +++ b/app/src/main/res/drawable/ic_picture_in_picture_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml b/app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml new file mode 100644 index 000000000..db1b46f81 --- /dev/null +++ b/app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_black_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_black_24dp.xml new file mode 100644 index 000000000..bf9b895ac --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_shadow.xml b/app/src/main/res/drawable/ic_play_arrow_shadow.xml new file mode 100644 index 000000000..8d5871fad --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_shadow.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml new file mode 100644 index 000000000..e135a55b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add_black_24dp.xml b/app/src/main/res/drawable/ic_playlist_add_black_24dp.xml new file mode 100644 index 000000000..905d86e64 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add_check_black_24dp.xml b/app/src/main/res/drawable/ic_playlist_add_check_black_24dp.xml new file mode 100644 index 000000000..4f7a1c13f --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add_check_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add_check_white_24dp.xml b/app/src/main/res/drawable/ic_playlist_add_check_white_24dp.xml new file mode 100644 index 000000000..04b4b7855 --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add_check_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_add_white_24dp.xml b/app/src/main/res/drawable/ic_playlist_add_white_24dp.xml new file mode 100644 index 000000000..ed27c167e --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_add_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_playlist_play_white_24dp.xml b/app/src/main/res/drawable/ic_playlist_play_white_24dp.xml new file mode 100644 index 000000000..06ccbb8eb --- /dev/null +++ b/app/src/main/res/drawable/ic_playlist_play_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_public_black_24dp.xml b/app/src/main/res/drawable/ic_public_black_24dp.xml new file mode 100644 index 000000000..d976b4244 --- /dev/null +++ b/app/src/main/res/drawable/ic_public_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_public_white_24dp.xml b/app/src/main/res/drawable/ic_public_white_24dp.xml new file mode 100644 index 000000000..880e42770 --- /dev/null +++ b/app/src/main/res/drawable/ic_public_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio_black_24dp.xml new file mode 100644 index 000000000..00da9101f --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable/ic_radio_white_24dp.xml new file mode 100644 index 000000000..df563ec1d --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 000000000..8229a9a64 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable/ic_refresh_white_24dp.xml new file mode 100644 index 000000000..cc2d1e04f --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat_white_24dp.xml b/app/src/main/res/drawable/ic_repeat_white_24dp.xml new file mode 100644 index 000000000..f4e1a4f39 --- /dev/null +++ b/app/src/main/res/drawable/ic_repeat_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml new file mode 100644 index 000000000..e14429d09 --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml new file mode 100644 index 000000000..1e2d89c0f --- /dev/null +++ b/app/src/main/res/drawable/ic_restaurant_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_rss_feed_black_24dp.xml b/app/src/main/res/drawable/ic_rss_feed_black_24dp.xml new file mode 100644 index 000000000..4da9b623b --- /dev/null +++ b/app/src/main/res/drawable/ic_rss_feed_black_24dp.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_rss_feed_white_24dp.xml b/app/src/main/res/drawable/ic_rss_feed_white_24dp.xml new file mode 100644 index 000000000..42a802c7e --- /dev/null +++ b/app/src/main/res/drawable/ic_rss_feed_white_24dp.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_save_black_24dp.xml b/app/src/main/res/drawable/ic_save_black_24dp.xml new file mode 100644 index 000000000..a561d632a --- /dev/null +++ b/app/src/main/res/drawable/ic_save_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_save_white_24dp.xml b/app/src/main/res/drawable/ic_save_white_24dp.xml new file mode 100644 index 000000000..74ca299c3 --- /dev/null +++ b/app/src/main/res/drawable/ic_save_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school_black_24dp.xml new file mode 100644 index 000000000..30d83f840 --- /dev/null +++ b/app/src/main/res/drawable/ic_school_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable/ic_school_white_24dp.xml new file mode 100644 index 000000000..e9fbe5931 --- /dev/null +++ b/app/src/main/res/drawable/ic_school_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml b/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml new file mode 100644 index 000000000..1372f04a0 --- /dev/null +++ b/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_add_black_24dp.xml b/app/src/main/res/drawable/ic_search_add_black_24dp.xml new file mode 100644 index 000000000..a5264a6a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_add_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_add_white_24dp.xml b/app/src/main/res/drawable/ic_search_add_white_24dp.xml new file mode 100644 index 000000000..9341522df --- /dev/null +++ b/app/src/main/res/drawable/ic_search_add_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_black_24dp.xml b/app/src/main/res/drawable/ic_search_black_24dp.xml new file mode 100644 index 000000000..affc7ba26 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 000000000..be5ad99c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml b/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml new file mode 100644 index 000000000..aa424c0d4 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_backup_restore_white_24dp.xml b/app/src/main/res/drawable/ic_settings_backup_restore_white_24dp.xml new file mode 100644 index 000000000..e3e6530bf --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_backup_restore_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_black_24dp.xml b/app/src/main/res/drawable/ic_settings_black_24dp.xml new file mode 100644 index 000000000..24a5623cd --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable/ic_settings_white_24dp.xml new file mode 100644 index 000000000..1397d370e --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_black_24dp.xml b/app/src/main/res/drawable/ic_share_black_24dp.xml new file mode 100644 index 000000000..e3fe874d6 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_share_white_24dp.xml b/app/src/main/res/drawable/ic_share_white_24dp.xml new file mode 100644 index 000000000..045bbc0c0 --- /dev/null +++ b/app/src/main/res/drawable/ic_share_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml new file mode 100644 index 000000000..452332095 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml new file mode 100644 index 000000000..a55bf8a88 --- /dev/null +++ b/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_shuffle_white_24dp.xml b/app/src/main/res/drawable/ic_shuffle_white_24dp.xml new file mode 100644 index 000000000..9ab22017b --- /dev/null +++ b/app/src/main/res/drawable/ic_shuffle_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort_black_24dp.xml b/app/src/main/res/drawable/ic_sort_black_24dp.xml new file mode 100644 index 000000000..fd4c56f0e --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable/ic_sort_white_24dp.xml new file mode 100644 index 000000000..a0c153ad0 --- /dev/null +++ b/app/src/main/res/drawable/ic_sort_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars_black_24dp.xml new file mode 100644 index 000000000..61c5d7ace --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable/ic_stars_white_24dp.xml new file mode 100644 index 000000000..926e5a106 --- /dev/null +++ b/app/src/main/res/drawable/ic_stars_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_subtitles_white_24dp.xml b/app/src/main/res/drawable/ic_subtitles_white_24dp.xml new file mode 100644 index 000000000..1052d1475 --- /dev/null +++ b/app/src/main/res/drawable/ic_subtitles_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope_black_24dp.xml new file mode 100644 index 000000000..9c6132ecc --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable/ic_telescope_white_24dp.xml new file mode 100644 index 000000000..ea870fd87 --- /dev/null +++ b/app/src/main/res/drawable/ic_telescope_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down_black_24dp.xml b/app/src/main/res/drawable/ic_thumb_down_black_24dp.xml new file mode 100644 index 000000000..26ba95c85 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_down_white_24dp.xml b/app/src/main/res/drawable/ic_thumb_down_white_24dp.xml new file mode 100644 index 000000000..72a99e6b0 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_down_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_up_black_24dp.xml b/app/src/main/res/drawable/ic_thumb_up_black_24dp.xml new file mode 100644 index 000000000..34fb51ab3 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thumb_up_white_24dp.xml b/app/src/main/res/drawable/ic_thumb_up_white_24dp.xml new file mode 100644 index 000000000..d9acf7500 --- /dev/null +++ b/app/src/main/res/drawable/ic_thumb_up_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml new file mode 100644 index 000000000..4c9da94b9 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml new file mode 100644 index 000000000..4d3859d53 --- /dev/null +++ b/app/src/main/res/drawable/ic_trending_up_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_tv_black_24dp.xml b/app/src/main/res/drawable/ic_tv_black_24dp.xml new file mode 100644 index 000000000..771363883 --- /dev/null +++ b/app/src/main/res/drawable/ic_tv_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_tv_white_24dp.xml b/app/src/main/res/drawable/ic_tv_white_24dp.xml new file mode 100644 index 000000000..0286ef16e --- /dev/null +++ b/app/src/main/res/drawable/ic_tv_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_asset_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_asset_black_24dp.xml new file mode 100644 index 000000000..52658f650 --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_asset_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_videogame_asset_white_24dp.xml b/app/src/main/res/drawable/ic_videogame_asset_white_24dp.xml new file mode 100644 index 000000000..46ec002cb --- /dev/null +++ b/app/src/main/res/drawable/ic_videogame_asset_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_down_white_24dp.xml b/app/src/main/res/drawable/ic_volume_down_white_24dp.xml new file mode 100644 index 000000000..3a769637b --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_down_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_mute_white_24dp.xml b/app/src/main/res/drawable/ic_volume_mute_white_24dp.xml new file mode 100644 index 000000000..dac85f981 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_mute_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_black_24dp.xml b/app/src/main/res/drawable/ic_volume_off_black_24dp.xml new file mode 100644 index 000000000..3aed66ddc --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_white_24dp.xml b/app/src/main/res/drawable/ic_volume_off_white_24dp.xml new file mode 100644 index 000000000..a266d9731 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_off_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml new file mode 100644 index 000000000..bb0c74ba1 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_up_white_24dp.xml b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml new file mode 100644 index 000000000..271540946 --- /dev/null +++ b/app/src/main/res/drawable/ic_volume_up_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_black_24dp.xml b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml new file mode 100644 index 000000000..5a1b9ac74 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml new file mode 100644 index 000000000..f9fffbc43 --- /dev/null +++ b/app/src/main/res/drawable/ic_watch_later_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wb_sunny_black_24dp.xml b/app/src/main/res/drawable/ic_wb_sunny_black_24dp.xml new file mode 100644 index 000000000..a56fb5049 --- /dev/null +++ b/app/src/main/res/drawable/ic_wb_sunny_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_wb_sunny_white_24dp.xml b/app/src/main/res/drawable/ic_wb_sunny_white_24dp.xml new file mode 100644 index 000000000..5d22bab00 --- /dev/null +++ b/app/src/main/res/drawable/ic_wb_sunny_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_whatshot_black_24dp.xml b/app/src/main/res/drawable/ic_whatshot_black_24dp.xml new file mode 100644 index 000000000..1cbc037f7 --- /dev/null +++ b/app/src/main/res/drawable/ic_whatshot_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_whatshot_white_24dp.xml b/app/src/main/res/drawable/ic_whatshot_white_24dp.xml new file mode 100644 index 000000000..9aa2124f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_whatshot_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_black_24dp.xml b/app/src/main/res/drawable/ic_work_black_24dp.xml new file mode 100644 index 000000000..2668f2c43 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable/ic_work_white_24dp.xml new file mode 100644 index 000000000..8a1db7828 --- /dev/null +++ b/app/src/main/res/drawable/ic_work_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/player_controls_background.xml b/app/src/main/res/drawable/player_controls_background.xml new file mode 100644 index 000000000..f250e3558 --- /dev/null +++ b/app/src/main/res/drawable/player_controls_background.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/player_controls_top_background.xml b/app/src/main/res/drawable/player_controls_top_background.xml new file mode 100644 index 000000000..ba62ce863 --- /dev/null +++ b/app/src/main/res/drawable/player_controls_top_background.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_circular_white.xml b/app/src/main/res/drawable/progress_circular_white.xml new file mode 100644 index 000000000..0de71afec --- /dev/null +++ b/app/src/main/res/drawable/progress_circular_white.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml new file mode 100644 index 000000000..bf6a3ae23 --- /dev/null +++ b/app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml b/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml new file mode 100644 index 000000000..0b3000de0 --- /dev/null +++ b/app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml b/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml new file mode 100644 index 000000000..7f4520eb8 --- /dev/null +++ b/app/src/main/res/drawable/progress_youtube_horizontal_dark.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/progress_youtube_horizontal_light.xml b/app/src/main/res/drawable/progress_youtube_horizontal_light.xml new file mode 100644 index 000000000..d1556de91 --- /dev/null +++ b/app/src/main/res/drawable/progress_youtube_horizontal_light.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_checked_dark.xml b/app/src/main/res/drawable/selector_checked_dark.xml new file mode 100644 index 000000000..59019470f --- /dev/null +++ b/app/src/main/res/drawable/selector_checked_dark.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_checked_light.xml b/app/src/main/res/drawable/selector_checked_light.xml new file mode 100644 index 000000000..b782a3688 --- /dev/null +++ b/app/src/main/res/drawable/selector_checked_light.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_dark.xml b/app/src/main/res/drawable/selector_dark.xml new file mode 100644 index 000000000..eb658e16d --- /dev/null +++ b/app/src/main/res/drawable/selector_dark.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/selector_focused_dark.xml b/app/src/main/res/drawable/selector_focused_dark.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/selector_focused_dark.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_focused_light.xml b/app/src/main/res/drawable/selector_focused_light.xml new file mode 100644 index 000000000..102f40d76 --- /dev/null +++ b/app/src/main/res/drawable/selector_focused_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/selector_light.xml b/app/src/main/res/drawable/selector_light.xml new file mode 100644 index 000000000..63f2ccaf3 --- /dev/null +++ b/app/src/main/res/drawable/selector_light.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml new file mode 100644 index 000000000..5b805cffa --- /dev/null +++ b/app/src/main/res/drawable/splash_background.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_foreground.xml b/app/src/main/res/drawable/splash_foreground.xml new file mode 100644 index 000000000..cfb650758 --- /dev/null +++ b/app/src/main/res/drawable/splash_foreground.xml @@ -0,0 +1,10 @@ + + + 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..dc472133f --- /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/drawable/toolbar_shadow_dark.xml b/app/src/main/res/drawable/toolbar_shadow_dark.xml new file mode 100644 index 000000000..d5ebfc8fd --- /dev/null +++ b/app/src/main/res/drawable/toolbar_shadow_dark.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/toolbar_shadow_light.xml b/app/src/main/res/drawable/toolbar_shadow_light.xml new file mode 100644 index 000000000..7b800786c --- /dev/null +++ b/app/src/main/res/drawable/toolbar_shadow_light.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml new file mode 100644 index 000000000..c33e14f9b --- /dev/null +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -0,0 +1,317 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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..c5aa806ed --- /dev/null +++ b/app/src/main/res/layout-large-land/activity_main_player.xml @@ -0,0 +1,653 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +