[verified] Publish ReelBox source under GPLv3

This commit is contained in:
Hermes committed 2026-11-12 23:41:13 +00:00
commit db2b505ca3
445 files changed
+105274

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Keep upstream code, license texts and raw evidence byte-for-byte.
# Whitespace checking remains enabled for first-party source and website code.
third_party/background_downloader/** -whitespace
third_party/licenses/** -whitespace
docs/verification-logs-0.2.0/** -whitespace
docs/verification-publication/** -whitespace
docs/verification-logs-0.4.2/** -whitespace
LICENSE -whitespace
android/gradlew.bat whitespace=cr-at-eol
+71
View File
@@ -0,0 +1,71 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Widget Preview related
.widget_preview/
# Never ship signing credentials or local tool locks
*.jks
*.keystore
*.p12
**/keystore.properties
.tooling.lock
# Private configuration and generated files
.env
.env.*
*.pem
*.key
*.pfx
*.db
*.sqlite
*.sqlite3
*.apk
*.aab
local.properties
.gradle/
.kotlin/
__pycache__/
+33
View File
@@ -0,0 +1,33 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "c197ca5199e9ee5661e865596c9c07294dfab51c"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
- platform: android
create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
- platform: web
create_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
base_revision: c197ca5199e9ee5661e865596c9c07294dfab51c
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://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.
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+29
View File
@@ -0,0 +1,29 @@
# ReelBox
ReelBox is an open-source Android app for watching films and series, released under the [GNU GPLv3](LICENSE). No account or subscription is required.
## Domestic network
ReelBox is designed for use on Iran's domestic network (ملانت), with its catalogue hosted inside Iran. Streaming and downloads still depend on the availability of each media host and your connection.
## Features
- Browse and search films and series, with genre, country and sorting filters
- Watch with the built-in player, choose available qualities, use subtitles and resume playback
- Download for offline viewing and manage downloads inside the app
- Keep favourites, collections, watched titles and viewing history on your device
- Export and restore your library
- Open media in an external player or downloader, with experimental Cast support
- English and Persian interfaces
## Beta
ReelBox is in beta. Many features are incomplete, experimental or buggy and will be improved in future updates.
## Catalogue
The current catalogue contains **30,009 titles: 20,474 films and 9,535 series**, based on the November 11, 2026 snapshot after excluding promotional and non-content entries. Availability can change, and not every title is guaranteed to play.
## Sponsor
This project was sponsored by [Buffalo VPN](https://t.me/vpnbufalo).
+25
View File
@@ -0,0 +1,25 @@
# Third-party notices
The top-level GPL-3.0-only license applies to Reelbox's original application and supporting code. Existing third-party notices are preserved; the top-level license does not replace them or grant rights in external media.
## Dart and Flutter
`pubspec.lock` identifies the resolved versions. `third_party/DEPENDENCIES.json` maps every resolved Dart/Flutter package to its upstream and retained license text in `third_party/licenses/dart/`. This includes build/test-only packages as well as runtime packages. Flutter and Dart SDKs are obtained separately, not redistributed in this repository.
The local `third_party/background_downloader/` tree is version 9.6.5 under its retained BSD-3-Clause license. The two Dart-only multipart resume fixes are described in `NOTICE.txt`; original copyright/license notices remain in place.
## Native media and Android libraries
The MIT-licensed `media_kit_libs_android_video` 1.3.8 Flutter plugin obtains its native ARM64 library from the **v1.1.7** build at https://github.com/media-kit/libmpv-android-video-build. Its exact download URL and checksum are specified in that package's `android/build.gradle`.
The plugin's MIT license must **not** be mistaken for the licenses of bundled mpv, FFmpeg and their codec/subtitle libraries. Their upstream source/build recipes and component license notices remain applicable. Native build recipes: https://github.com/media-kit/libmpv-android-video-build/tree/v1.1.7. mpv source: https://github.com/mpv-player/mpv. FFmpeg source: https://ffmpeg.org/download.html. Consult those exact component revisions/build recipes before redistributing a modified native stack.
Android platform/support libraries and Google Play services Cast Framework are separately resolved by Gradle. Direct native dependencies are pinned in `android/app/build.gradle.kts`; Gradle/Android SDK components are not relicensed by the project's GPL grant. See their distributed notices and applicable upstream terms. This inventory is not a certification of every possible dependency combination or app-store eligibility.
The Material Icons font is a Flutter-provided asset under its own Apache-2.0 notice, retained in `third_party/licenses/material-icons.txt`.
## Website
The screenshot is a host-rendered capture of Reelbox's actual interface, not evidence of Android hardware behavior. It contains no third-party film artwork.
Design process: **Unslop by Damir Sokolovsky (@heydamir)**, https://heydamir.design/unslop, CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). The website is an original implementation using that process, not a copy of the upstream reference layouts. Changes include a copy-only cleanup retaining Reelbox’s existing cinema direction. Attribution is retained here, in HTML/CSS source comments and retained source comments, rather than visible promotional copy.
+34
View File
@@ -0,0 +1,34 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
- web/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+14
View File
@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
+94
View File
@@ -0,0 +1,94 @@
import java.util.Properties
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
val signingFile = file(System.getenv("REELBOX_SIGNING_PROPERTIES")
?: "${System.getProperty("user.home")}/.config/reelbox-signing/keystore.properties")
val signingProperties = Properties().apply {
if (signingFile.isFile) signingFile.inputStream().use { load(it) }
}
android {
signingConfigs {
create("release") {
if (signingFile.isFile) {
storeFile = file(signingProperties.getProperty("storeFile"))
storePassword = signingProperties.getProperty("storePassword")
keyAlias = signingProperties.getProperty("keyAlias")
keyPassword = signingProperties.getProperty("keyPassword")
}
}
}
namespace = "org.reelbox.reelbox"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
// Dependency release-lint analysis exceeded this host's memory/time budget.
// Run Android lint separately on a larger machine before store distribution.
lint {
checkReleaseBuilds = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "org.reelbox.app"
// This delivery targets ARM64. Exclude dependency-only ABIs that lack
// Flutter's libapp/libflutter and would falsely advertise support.
ndk {
abiFilters.clear()
abiFilters.add("arm64-v8a")
}
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = maxOf(flutter.minSdkVersion, 24) // Native default-network callback / scoped media lifecycle.
targetSdk = flutter.targetSdkVersion
// Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
// is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
// You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
// flag during build.
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// Missing release keys fail closed; never use Android's debug signer.
signingConfig = signingConfigs.getByName("release")
// Keep Dart AOT release mode; avoid the Java R8/resource shrinker
// on the constrained build host. This increases APK size.
isMinifyEnabled = false
isShrinkResources = false
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
dependencies {
// Maven Central metadata verified 2026-11-04: latest stable 3.7.7;
// 4.0.0-eap29 is a prerelease. API checked against published sources/classes.jar.
implementation("org.videolan.android:libvlc-all:3.7.7")
// Google currently documents 23.0.0, but its MediaRouter 1.9 beta requires
// compileSdk 37. Use verified 22.3.1 on this repository's installed SDK 35.
implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.core:core:1.16.0")
implementation("androidx.mediarouter:mediarouter:1.8.1")
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+87
View File
@@ -0,0 +1,87 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />
<application
android:label="Reelbox"
android:usesCleartextTraffic="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name="org.reelbox.reelbox.MainActivity"
android:supportsPictureInPicture="true"
android:resizeableActivity="true"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="org.reelbox.reelbox.ReelboxCastOptions" />
<activity android:name="org.reelbox.reelbox.CastPickerActivity"
android:exported="false" android:theme="@style/ReelboxCastTheme" />
<activity android:name="org.reelbox.reelbox.EmbeddedVideoActivity"
android:exported="false" android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize"
android:theme="@android:style/Theme.Material.NoActionBar" />
<activity android:name="org.reelbox.reelbox.VlcPlayerActivity"
android:exported="false" android:hardwareAccelerated="true"
android:supportsPictureInPicture="true" android:resizeableActivity="true"
android:enableOnBackInvokedCallback="true"
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout|uiMode|density"
android:theme="@android:style/Theme.Material.NoActionBar" />
<service android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync" tools:node="merge" />
<service android:name="org.reelbox.reelbox.BackgroundPlaybackService"
android:exported="false" android:stopWithTask="true"
android:foregroundServiceType="mediaPlayback" />
<provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files" android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/reelbox_file_paths" />
</provider>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="video/*" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,209 @@
package org.reelbox.reelbox
import android.app.Notification
import android.app.NotificationChannel
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.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaMetadata
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import io.flutter.plugin.common.MethodChannel
/** The existing Flutter engine/Player owns decoding and is the sole event consumer. */
object NativeMediaEvents {
var channel: MethodChannel? = null
fun action(action: String, positionMs: Long? = null) {
val arguments = mutableMapOf<String, Any>("action" to action)
if (positionMs != null) arguments["positionMs"] = positionMs
Handler(Looper.getMainLooper()).post { channel?.invokeMethod("mediaAction", arguments) }
}
}
/** Foreground lifecycle/session/notification bridge. Deliberately contains NO decoder. */
class BackgroundPlaybackService : Service() {
companion object {
private const val CHANNEL = "reelbox_playback"
private const val NOTIFICATION_ID = 3041
private const val UPDATE = "org.reelbox.app.UPDATE_PLAYBACK"
private const val CONTROL = "org.reelbox.app.MEDIA_CONTROL"
private var running: BackgroundPlaybackService? = null
fun start(context: Context, args: Map<*, *>) {
require(NativeMediaEvents.channel != null) { "No active Flutter media owner." }
val intent = Intent(context, BackgroundPlaybackService::class.java).setAction(UPDATE)
val snapshot = PlaybackSnapshot.from(args)
intent.putExtra("title", snapshot.title).putExtra("playing", snapshot.playing)
.putExtra("positionMs", snapshot.positionMs).putExtra("durationMs", snapshot.durationMs)
if (Build.VERSION.SDK_INT >= 26) context.startForegroundService(intent) else context.startService(intent)
}
fun update(args: Map<*, *>) { running?.applySnapshot(PlaybackSnapshot.from(args, running!!.snapshot)) }
fun stop(context: Context) { context.stopService(Intent(context, BackgroundPlaybackService::class.java)) }
}
private lateinit var session: MediaSession
private lateinit var audio: AudioManager
private var snapshot = PlaybackSnapshot()
private var focusRequest: AudioFocusRequest? = null
private var focusHeld = false
private var interrupted = false
private var resumeOnFocus = false
private var noisyRegistered = false
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_GAIN -> {
interrupted = false
if (resumeOnFocus) NativeMediaEvents.action("play")
resumeOnFocus = false
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
resumeOnFocus = snapshot.playing
interrupted = true
NativeMediaEvents.action("pause")
}
AudioManager.AUDIOFOCUS_LOSS -> {
interrupted = false
resumeOnFocus = false
abandonFocus()
NativeMediaEvents.action("pause")
}
}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) NativeMediaEvents.action("pause")
}
}
override fun onCreate() {
super.onCreate()
running = this
audio = getSystemService(AUDIO_SERVICE) as AudioManager
if (Build.VERSION.SDK_INT >= 26) {
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(
NotificationChannel(CHANNEL, "Background playback", NotificationManager.IMPORTANCE_LOW)
)
}
session = MediaSession(this, "ReelboxPlayback").apply {
setCallback(object : MediaSession.Callback() {
override fun onPlay() = NativeMediaEvents.action("play")
override fun onPause() = NativeMediaEvents.action("pause")
override fun onStop() { NativeMediaEvents.action("stop"); stopSelf() }
override fun onSeekTo(pos: Long) = NativeMediaEvents.action("seek", pos.coerceAtLeast(0))
override fun onSkipToNext() = NativeMediaEvents.action("next")
override fun onSkipToPrevious() = NativeMediaEvents.action("previous")
}, Handler(Looper.getMainLooper()))
isActive = true
}
val filter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, filter, Context.RECEIVER_NOT_EXPORTED)
else @Suppress("DEPRECATION") registerReceiver(noisy, filter)
noisyRegistered = true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (NativeMediaEvents.channel == null || intent == null) { stopSelf(); return START_NOT_STICKY }
if (intent.action == CONTROL) {
val action = intent.getStringExtra("action")
if (action in listOf("play", "pause", "stop", "next", "previous")) NativeMediaEvents.action(action!!)
if (action == "stop") stopSelf()
} else if (intent.action == UPDATE) {
snapshot = PlaybackSnapshot.from(mapOf(
"title" to intent.getStringExtra("title"), "playing" to intent.getBooleanExtra("playing", false),
"positionMs" to intent.getLongExtra("positionMs", 0), "durationMs" to intent.getLongExtra("durationMs", 0)
), snapshot)
// Android 15 audio-focus eligibility requires foreground state BEFORE focus request.
updateSession()
if (Build.VERSION.SDK_INT >= 29) startForeground(NOTIFICATION_ID, notification(), ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
else startForeground(NOTIFICATION_ID, notification())
updateFocus()
}
return START_NOT_STICKY
}
private fun applySnapshot(value: PlaybackSnapshot) {
snapshot = value
updateSession()
updateFocus()
(getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification())
}
private fun updateSession() {
session.setMetadata(MediaMetadata.Builder().putString(MediaMetadata.METADATA_KEY_TITLE, snapshot.title)
.putLong(MediaMetadata.METADATA_KEY_DURATION, snapshot.durationMs).build())
session.setPlaybackState(PlaybackState.Builder().setActions(
PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or PlaybackState.ACTION_PLAY_PAUSE or
PlaybackState.ACTION_STOP or PlaybackState.ACTION_SEEK_TO or PlaybackState.ACTION_SKIP_TO_NEXT or PlaybackState.ACTION_SKIP_TO_PREVIOUS
).setState(if (snapshot.playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
snapshot.positionMs, if (snapshot.playing) 1f else 0f).build())
}
@Suppress("DEPRECATION")
private fun updateFocus() {
if (!snapshot.playing) { if (!interrupted) abandonFocus(); return }
if (focusHeld) return
val granted = if (Build.VERSION.SDK_INT >= 26) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
.setWillPauseWhenDucked(true).setOnAudioFocusChangeListener(focusListener).build()
focusRequest = request
audio.requestAudioFocus(request)
} else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
focusHeld = granted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
if (!focusHeld) NativeMediaEvents.action("pause")
}
@Suppress("DEPRECATION")
private fun abandonFocus() {
if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) }
else if (focusHeld) audio.abandonAudioFocus(focusListener)
focusHeld = false
focusRequest = null
}
private fun control(action: String): PendingIntent = PendingIntent.getService(this, action.hashCode(),
Intent(this, BackgroundPlaybackService::class.java).setAction(CONTROL).putExtra("action", action),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
private fun notification(): Notification {
val builder = if (Build.VERSION.SDK_INT >= 26) Notification.Builder(this, CHANNEL) else @Suppress("DEPRECATION") Notification.Builder(this)
val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return builder.setSmallIcon(R.drawable.ic_playback).setContentTitle(snapshot.title).setContentText("Reelbox")
.setContentIntent(open).setVisibility(Notification.VISIBILITY_PUBLIC).setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_TRANSPORT).setOngoing(snapshot.playing)
.setDeleteIntent(control("stop"))
.addAction(Notification.Action.Builder(android.R.drawable.ic_media_previous, "Previous", control("previous")).build())
.addAction(Notification.Action.Builder(if (snapshot.playing) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play,
if (snapshot.playing) "Pause" else "Play", control(if (snapshot.playing) "pause" else "play")).build())
.addAction(Notification.Action.Builder(android.R.drawable.ic_media_next, "Next", control("next")).build())
.addAction(Notification.Action.Builder(android.R.drawable.ic_menu_close_clear_cancel, "Stop", control("stop")).build())
.setStyle(Notification.MediaStyle().setMediaSession(session.sessionToken).setShowActionsInCompactView(0, 1, 2))
.build()
}
override fun onTaskRemoved(rootIntent: Intent?) { NativeMediaEvents.action("stop"); stopSelf(); super.onTaskRemoved(rootIntent) }
override fun onDestroy() {
if (running === this) running = null
abandonFocus()
if (noisyRegistered) unregisterReceiver(noisy)
session.isActive = false
session.release()
if (Build.VERSION.SDK_INT >= 24) stopForeground(STOP_FOREGROUND_REMOVE) else @Suppress("DEPRECATION") stopForeground(true)
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
}
@@ -0,0 +1,244 @@
package org.reelbox.reelbox
import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.Uri
import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.DownloadListener
import android.webkit.PermissionRequest
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.TextView
import java.io.ByteArrayInputStream
/** Restricted video embed host, not a vendor storefront or general external browser. */
class EmbeddedVideoActivity : Activity() {
companion object { private const val FILE_REQUEST = 6104 }
private lateinit var web: WebView
private lateinit var root: LinearLayout
private lateinit var screen: FrameLayout
private lateinit var status: TextView
private lateinit var home: String
private val resourceFetcher = RestrictedResourceFetcher()
@Volatile private var securePage = false
private var customView: View? = null
private var customCallback: WebChromeClient.CustomViewCallback? = null
private var oldOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private var oldUi = 0
private var fileCallback: ValueCallback<Array<Uri>>? = null
private var networkRegistered = false
private lateinit var connectivity: ConnectivityManager
private val network = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = updateConnectivity()
override fun onLost(network: Network) = updateConnectivity()
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) = updateConnectivity()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
home = intent.getStringExtra("url").orEmpty()
if (!NativeUrlPolicy.allowsRemote(home)) { finish(); return }
securePage = home.startsWith("https:", true)
title = intent.getStringExtra("title")?.take(512) ?: "Embedded video"
screen = FrameLayout(this)
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL; setBackgroundColor(Color.BLACK) }
val tools = LinearLayout(this)
fun control(label: String, action: () -> Unit) {
tools.addView(Button(this).apply { text = label; setOnClickListener { action() } }, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
}
control("Back") { back() }
control("Home") { load(home) }
control("Refresh") { web.reload() }
control("Close") { finish() }
root.addView(tools)
status = TextView(this).apply { setTextColor(Color.WHITE); setPadding(12, 4, 12, 4) }
root.addView(status)
web = WebView(this)
root.addView(web, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
screen.addView(root)
setContentView(screen)
web.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
allowFileAccess = false
allowContentAccess = false
javaScriptCanOpenWindowsAutomatically = false
setSupportMultipleWindows(false)
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
mediaPlaybackRequiresUserGesture = true
if (Build.VERSION.SDK_INT >= 26) safeBrowsingEnabled = true
}
CookieManager.getInstance().setAcceptThirdPartyCookies(web, false)
web.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val allowed = NativeUrlPolicy.allowsRemote(request.url.toString())
if (!allowed && request.isForMainFrame) message("This destination is not permitted.")
return !allowed
}
@Suppress("DEPRECATION")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean = !NativeUrlPolicy.allowsRemote(url)
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse {
val response = resourceFetcher.fetch(request.url.toString(), request.method, request.requestHeaders,
secureContext = !request.isForMainFrame && securePage)
if (request.isForMainFrame) {
if (response.effectiveUrl.isNotEmpty()) securePage = response.effectiveUrl.startsWith("https:", true)
if (response.statusCode in listOf(401, 403, 451)) {
message("This source or destination denied access. Authorization cannot be bypassed.")
} else if (response.statusCode >= 500) message("Video page unavailable. Check connectivity and use Refresh.")
}
return resourceResponse(response)
}
@Suppress("DEPRECATION")
override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse {
return resourceResponse(resourceFetcher.fetch(url, secureContext = securePage))
}
override fun onReceivedSslError(view: WebView, handler: SslErrorHandler, error: SslError) {
handler.cancel()
message("Secure connection failed. Playback was not authorized.")
}
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, response: WebResourceResponse) {
if (request.isForMainFrame && response.statusCode in listOf(401, 403, 451)) {
view.stopLoading()
message("This source denied access. Reelbox cannot bypass authorization or DRM.")
}
}
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
if (request.isForMainFrame) message("Video page unavailable. Check connectivity and use Refresh.")
}
override fun onPageFinished(view: WebView, url: String) { updateConnectivity() }
}
web.webChromeClient = object : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
if (customView != null) { callback.onCustomViewHidden(); return }
customView = view
customCallback = callback
oldOrientation = requestedOrientation
@Suppress("DEPRECATION")
oldUi = window.decorView.systemUiVisibility
root.visibility = View.GONE
screen.addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
override fun onHideCustomView() = hideFullscreen()
override fun onShowFileChooser(view: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
fileCallback?.onReceiveValue(null)
fileCallback = callback
val picker = Intent(Intent.ACTION_OPEN_DOCUMENT).addCategory(Intent.CATEGORY_OPENABLE).setType("*/*")
.putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("video/*", "audio/*", "text/vtt", "application/x-subrip"))
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return try { @Suppress("DEPRECATION") startActivityForResult(picker, FILE_REQUEST); true }
catch (_: Exception) { fileCallback?.onReceiveValue(null); fileCallback = null; false }
}
}
web.setDownloadListener(DownloadListener { url, _, contentDisposition, mime, _ ->
val name = URLUtil.guessFileName(url, contentDisposition, mime)
if (!NativeUrlPolicy.allowsRemote(url) || mime.orEmpty().contains("android.package", true) ||
Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name)) {
message("This download is not permitted.")
} else AlertDialog.Builder(this).setTitle("Download media?")
.setMessage("Send $name to an installed download manager? Web credentials are not forwarded.")
.setNegativeButton("Cancel", null).setPositiveButton("Choose downloader") { _, _ ->
AlertDialog.Builder(this).setTitle("Download with")
.setItems(arrayOf("ADM", "1DM / IDM", "Other app")) { _, index ->
try {
val ok = NativeFileIntents(this).openDownloader(mapOf("url" to url, "filename" to name,
"packageName" to listOf("ADM", "IDM", "ask")[index]))
if (!ok) message("No compatible download manager is installed.")
} catch (_: Exception) { message("Unable to hand off this download.") }
}.show()
}.show()
})
connectivity = getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager
try { connectivity.registerDefaultNetworkCallback(network); networkRegistered = true } catch (_: Exception) { }
if (Build.VERSION.SDK_INT >= 33) onBackInvokedDispatcher.registerOnBackInvokedCallback(
android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT) { back() }
if (savedInstanceState != null) web.restoreState(savedInstanceState) else load(home)
updateConnectivity()
}
private fun load(url: String) {
if (NativeUrlPolicy.allowsRemote(url)) { hideFullscreen(); web.loadUrl(url) }
else message("This destination is not permitted.")
}
private fun resourceResponse(response: RestrictedResourceFetcher.Response): WebResourceResponse = try {
WebResourceResponse(response.mimeType, response.encoding, response.statusCode,
response.reasonPhrase, response.headers, response.body)
} catch (_: IllegalArgumentException) {
// Never fall back to WebView networking if Android rejects response metadata.
try { response.body.close() } catch (_: Exception) { }
WebResourceResponse("text/plain", "UTF-8", 502, "Unavailable", emptyMap(), ByteArrayInputStream(ByteArray(0)))
}
private fun message(text: String) { runOnUiThread { if (::status.isInitialized) status.text = text } }
private fun updateConnectivity() {
if (!::connectivity.isInitialized) return
val caps = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
message(if (caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true) "Embedded video · network connected" else "Offline · reconnect and tap Refresh")
}
private fun hideFullscreen() {
val view = customView ?: return
screen.removeView(view)
customView = null
root.visibility = View.VISIBLE
@Suppress("DEPRECATION")
window.decorView.systemUiVisibility = oldUi
requestedOrientation = oldOrientation
customCallback?.onCustomViewHidden()
customCallback = null
}
private fun back() { if (customView != null) hideFullscreen() else if (web.canGoBack()) web.goBack() else finish() }
@Deprecated("System back uses OnBackInvokedDispatcher on Android 13+")
override fun onBackPressed() = back()
override fun onSaveInstanceState(outState: Bundle) { if (::web.isInitialized) web.saveState(outState); super.onSaveInstanceState(outState) }
@Deprecated("Activity result contract retained for the platform WebView file chooser")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != FILE_REQUEST) return
val uris = if (resultCode == RESULT_OK) {
val clips = data?.clipData
if (clips != null) (0 until clips.itemCount.coerceAtMost(10)).map { clips.getItemAt(it).uri }
else listOfNotNull(data?.data)
} else emptyList()
fileCallback?.onReceiveValue(uris.filter { it.scheme == "content" }.takeIf { it.isNotEmpty() }?.toTypedArray())
fileCallback = null
}
override fun onPause() { if (::web.isInitialized) web.onPause(); super.onPause() }
override fun onResume() { super.onResume(); if (::web.isInitialized) web.onResume() }
override fun onDestroy() {
if (networkRegistered) connectivity.unregisterNetworkCallback(network)
fileCallback?.onReceiveValue(null)
fileCallback = null
hideFullscreen()
if (::web.isInitialized) {
web.stopLoading()
(web.parent as? ViewGroup)?.removeView(web)
web.destroy()
}
super.onDestroy()
}
}
@@ -0,0 +1,175 @@
package org.reelbox.reelbox
import android.app.PictureInPictureParams
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.util.Rational
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/** Window-scoped brightness, system stream volume, ACTION_VIEW and Android PiP.
* No overlay, storage, write-settings or broad media permissions are requested. */
class MainActivity : FlutterActivity() {
private lateinit var mediaChannel: MethodChannel
private var playerActive = false
private var nativePlayerRequestId: String? = null
override fun onCreate(savedInstanceState: android.os.Bundle?) {
nativePlayerRequestId = savedInstanceState?.getString(NativePlayerRequests.REQUEST_ID)
super.onCreate(savedInstanceState)
}
override fun onSaveInstanceState(outState: android.os.Bundle) {
nativePlayerRequestId?.let { outState.putString(NativePlayerRequests.REQUEST_ID, it) }
super.onSaveInstanceState(outState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != NativePlayerRequests.REQUEST_CODE) return
val id = data?.getStringExtra(NativePlayerRequests.REQUEST_ID) ?: nativePlayerRequestId ?: return
if (id != NativePlayerRequests.results.activeId) return
if (data?.hasExtra("positionMs") == true) {
NativePlayerRequests.results.update(id, data.getLongExtra("positionMs", 0),
data.getLongExtra("durationMs", 0), data.getBooleanExtra("opened", false),
data.getBooleanExtra("completed", false))
}
nativePlayerRequestId = null
NativePlayerRequests.results.finish(id)
}
private var cast: ReelboxCast? = null
private fun castBridge(): ReelboxCast = cast ?: ReelboxCast(this, mediaChannel).also { cast = it }
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
mediaChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "org.reelbox/media")
NativeMediaEvents.channel = mediaChannel
mediaChannel.setMethodCallHandler { call, result ->
try {
when (call.method) {
"setPlayerActive" -> {
playerActive = call.argument<Boolean>("active") ?: false
result.success(null)
}
"isInPip" -> result.success(Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode)
"enterPip" -> result.success(enterPlayerPip())
"getVolume" -> {
val audio = getSystemService(AUDIO_SERVICE) as AudioManager
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
result.success(if (max > 0) audio.getStreamVolume(AudioManager.STREAM_MUSIC).toDouble() / max else 0.0)
}
"setVolume" -> {
val audio = getSystemService(AUDIO_SERVICE) as AudioManager
val value = (call.argument<Number>("value")?.toDouble() ?: 0.5).coerceIn(0.0, 1.0)
val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
audio.setStreamVolume(AudioManager.STREAM_MUSIC, (value * max).toInt(), 0)
result.success(null)
}
"getBrightness" -> result.success(window.attributes.screenBrightness.toDouble())
"getEffectiveBrightness" -> {
val windowValue = window.attributes.screenBrightness
val value = if (windowValue >= 0) windowValue.toDouble() else
android.provider.Settings.System.getInt(contentResolver,
android.provider.Settings.System.SCREEN_BRIGHTNESS, 128) / 255.0
result.success(value.coerceIn(0.0, 1.0))
}
"setBrightness" -> {
val value = call.argument<Number>("value")?.toFloat() ?: -1f
val attributes = window.attributes
attributes.screenBrightness = if (value < 0) -1f else value.coerceIn(0.01f, 1f)
window.attributes = attributes
result.success(null)
}
"backgroundPlayback" -> {
if (call.argument<Boolean>("enabled") == true) {
if (Build.VERSION.SDK_INT >= 33 && checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), 3041)
}
BackgroundPlaybackService.start(this, call.arguments as? Map<*, *> ?: emptyMap<Any, Any>())
} else BackgroundPlaybackService.stop(this)
result.success(null)
}
"backgroundPlaybackUpdate" -> {
BackgroundPlaybackService.update(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>())
result.success(null)
}
"backgroundPlaybackStop" -> { BackgroundPlaybackService.stop(this); result.success(null) }
"castShowPicker" -> result.success(castBridge().showPicker())
"castGetState" -> result.success(castBridge().getState())
"castLoad" -> castBridge().load(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(), result)
"castCommand" -> castBridge().command(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(), result)
"castDisconnect" -> { castBridge().disconnect(); result.success(null) }
"openNativePlayer" -> {
val launch = NativePlayerLaunch.from(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>(),
VlcPlayerActivity.ownedRoots(this))
val id = java.util.UUID.randomUUID().toString()
if (!NativePlayerRequests.results.begin(id, launch.positionMs) { result.success(it) }) {
result.error("NATIVE_PLAYER_BUSY", "A native player is already open.", null)
} else {
nativePlayerRequestId = id
try {
startActivityForResult(VlcPlayerActivity.intent(this, launch, id), NativePlayerRequests.REQUEST_CODE)
} catch (_: Exception) {
NativePlayerRequests.results.finish(id)
nativePlayerRequestId = null
}
}
}
"openExternal" -> result.success(NativeFileIntents(this).openExternal(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"openDownloader" -> result.success(NativeFileIntents(this).openDownloader(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"revealFile" -> result.success(NativeFileIntents(this).revealFile(call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()))
"openEmbedded" -> {
val url = call.argument<String>("url").orEmpty()
require(NativeUrlPolicy.allowsRemote(url))
startActivity(Intent(this, EmbeddedVideoActivity::class.java).putExtra("url", url)
.putExtra("title", call.argument<String>("title").orEmpty()))
result.success(true)
}
else -> result.notImplemented()
}
} catch (error: Exception) {
result.error("MEDIA_PLATFORM", "Android could not perform this media operation.", null)
}
}
}
override fun onDestroy() {
// Keep a pending reply through host configuration changes; a permanently
// closing host must not leak the result callback or native activity.
if (!isChangingConfigurations && nativePlayerRequestId != null &&
nativePlayerRequestId == NativePlayerRequests.results.activeId) {
finishActivity(NativePlayerRequests.REQUEST_CODE)
NativePlayerRequests.results.dispose()
nativePlayerRequestId = null
}
cast?.dispose()
cast = null
if (::mediaChannel.isInitialized && NativeMediaEvents.channel === mediaChannel) {
BackgroundPlaybackService.stop(this)
NativeMediaEvents.channel = null
mediaChannel.setMethodCallHandler(null)
}
super.onDestroy()
}
private fun enterPlayerPip(): Boolean {
if (Build.VERSION.SDK_INT < 26 || !packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false
return enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build())
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (playerActive) enterPlayerPip()
}
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (::mediaChannel.isInitialized) mediaChannel.invokeMethod("pipChanged", inPip)
}
}
@@ -0,0 +1,111 @@
package org.reelbox.reelbox
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Intent
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import java.io.File
/** Only scoped content grants leave the app; never file:// or broad storage grants. */
class NativeFileIntents(private val activity: Activity) {
private fun localFile(path: String): File {
val file = File(path).canonicalFile
val roots = listOfNotNull(activity.filesDir, activity.cacheDir,
activity.getExternalFilesDir(null), activity.externalCacheDir).map { it.canonicalFile }
require(roots.any { file.path.startsWith(it.path + File.separator) } && file.isFile && file.canRead()) { "File is not in app-owned storage." }
require(!file.name.endsWith(".apk", true) && !file.name.endsWith(".apks", true) && !file.name.endsWith(".xapk", true) && !file.name.endsWith(".aab", true))
return file
}
private fun shareUri(file: File): Uri = FileProvider.getUriForFile(activity, "${activity.packageName}.files", file)
private fun mediaUri(value: String): Uri {
val parsed = Uri.parse(value)
return when (parsed.scheme?.lowercase()) {
"http", "https" -> { require(NativeUrlPolicy.allowsRemote(value)); parsed }
"content" -> {
require(!parsed.authority.isNullOrBlank())
// Confirm this process really has read access; forwarding arbitrary provider URIs is forbidden.
activity.contentResolver.openAssetFileDescriptor(parsed, "r")?.use { } ?: throw IllegalArgumentException("Unreadable content URI.")
parsed
}
"file" -> shareUri(localFile(parsed.path ?: ""))
null -> shareUri(localFile(value))
else -> throw IllegalArgumentException("Unsupported media URI.")
}
}
private fun grant(intent: Intent, uris: List<Uri>) {
val content = uris.filter { it.scheme == "content" }
if (content.isEmpty()) return
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
intent.clipData = ClipData.newRawUri("Reelbox media", content.first()).also { clip ->
content.drop(1).forEach { clip.addItem(ClipData.Item(it)) }
}
}
private fun launch(intent: Intent, packageName: String?, title: String): Boolean {
if (!packageName.isNullOrBlank() && packageName != "ask") {
require(NativeUrlPolicy.safePackage(packageName))
try { activity.startActivity(Intent(intent).setPackage(packageName)); return true }
catch (_: ActivityNotFoundException) { /* Offer an installed alternative. */ }
}
return try {
activity.startActivity(Intent.createChooser(Intent(intent).setPackage(null), title)); true
} catch (_: ActivityNotFoundException) { false }
}
fun openExternal(args: Map<*, *>): Boolean {
val uri = mediaUri(args["url"] as? String ?: "")
val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { mediaUri(it) }
val position = ((args["positionMs"] as? Number)?.toLong() ?: 0).coerceAtLeast(0)
val requested = args["packageName"] as? String
val target = when (requested?.lowercase()) {
"vlc" -> "org.videolan.vlc"
"mx", "mxplayer" -> "com.mxtech.videoplayer.ad"
else -> requested
}
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(uri, "video/*")
.putExtra(Intent.EXTRA_TITLE, (args["title"] as? String).orEmpty().take(512))
// VLC's public Android API uses a long position; MX Player uses int milliseconds.
if (target?.startsWith("com.mxtech.") == true) intent.putExtra("position", position.coerceAtMost(Int.MAX_VALUE.toLong()).toInt())
else intent.putExtra("position", position)
if (subtitle != null) {
intent.putExtra("subtitles_location", subtitle.toString()) // VLC Android public API.
intent.putExtra("subs", arrayOf(subtitle)) // MX Player public API.
intent.putExtra("subs.enable", arrayOf(subtitle))
}
grant(intent, listOfNotNull(uri, subtitle))
return launch(intent, target, "Open video with")
}
fun revealFile(args: Map<*, *>): Boolean {
val file = localFile(args["path"] as? String ?: "")
val uri = shareUri(file)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension.lowercase()) ?: "application/octet-stream"
// Shares/export via Android's scoped chooser (including installed document/save targets).
val intent = Intent(Intent.ACTION_SEND).setType(mime).putExtra(Intent.EXTRA_STREAM, uri)
.putExtra(Intent.EXTRA_TITLE, file.name)
grant(intent, listOf(uri))
return launch(intent, "ask", "Export ${file.name}")
}
fun openDownloader(args: Map<*, *>): Boolean {
val url = args["url"] as? String ?: ""
require(NativeUrlPolicy.allowsRemote(url))
val requested = args["packageName"] as? String ?: "ask"
val target = when (requested.lowercase()) {
"adm" -> "com.dv.adm"
"idm", "1dm" -> "idm.internet.download.manager"
else -> requested
}
val name = (args["filename"] as? String).orEmpty().substringAfterLast('/').substringAfterLast('\\').take(240)
require(!Regex("\\.(apk|apks|xapk|aab)$", RegexOption.IGNORE_CASE).containsMatchIn(name))
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(Uri.parse(url), "application/octet-stream")
.putExtra(Intent.EXTRA_TITLE, name)
return launch(intent, target, "Send download to")
}
}
@@ -0,0 +1,119 @@
package org.reelbox.reelbox
import java.io.File
import java.net.URI
import java.util.Locale
/** Immutable launch contract; remote source strings are never rewritten. */
data class NativePlayerLaunch(
val url: String,
val title: String,
val positionMs: Long,
val autoplay: Boolean,
val externalPackage: String,
val orientation: String,
val videoFit: String,
val speed: Float,
val subtitleEnabled: Boolean,
val subtitleUrl: String?,
val hardwareAcceleration: Boolean,
val pipEnabled: Boolean,
) {
companion object {
/** Accept remote HTTP(S), including a parent-owned loopback HLS lease,
* and readable canonical files strictly inside app-owned roots. No content
* providers, file authorities, APKs, arbitrary paths or URL credentials. */
fun source(value: String, roots: List<File>): String {
require(value.isNotBlank() && value.none { it.code < 32 || it.code == 127 })
if (value.startsWith('/')) return ownedFile(File(value), roots)
val uri = URI(value)
return when (uri.scheme?.lowercase(Locale.ROOT)) {
"http", "https" -> {
require(!uri.host.isNullOrBlank() && uri.rawUserInfo == null && !value.contains('\\'))
// A loopback lease is generated by this app, not catalog traffic.
val loopback = uri.host in listOf("127.0.0.1", "[::1]", "::1")
require(loopback || NativeUrlPolicy.allowsRemote(value))
value
}
"file" -> {
require(uri.rawAuthority.isNullOrEmpty() && uri.rawQuery == null && uri.rawFragment == null)
ownedFile(File(uri), roots)
}
else -> throw IllegalArgumentException("Unsupported media source")
}
}
private fun ownedFile(file: File, roots: List<File>): String {
val canonical = file.canonicalFile
require(roots.any { canonical.path.startsWith(it.canonicalPath + File.separator) })
require(canonical.isFile && canonical.canRead() && NativeUrlPolicy.allowsSharedFilename(canonical.name))
return canonical.path
}
fun from(args: Map<*, *>, roots: List<File>): NativePlayerLaunch {
val url = source(args["url"] as? String ?: "", roots)
val speed = (args["speed"] as? Number)?.toFloat() ?: 1f
require(speed.isFinite() && speed in 0.25f..3f)
val orientation = (args["orientation"] as? String) ?: "auto"
require(orientation in listOf("auto", "portrait", "landscape"))
val fit = (args["videoFit"] as? String) ?: "contain"
require(fit in listOf("contain", "cover", "fill"))
val external = (args["externalPackage"] as? String) ?: "ask"
require(external in listOf("ask", "vlc", "mx", "mxplayer") || NativeUrlPolicy.safePackage(external))
val position = args["positionMs"] as? Number
require(position == null || position.toDouble().isFinite())
return NativePlayerLaunch(url, (args["title"] as? String).orEmpty().take(512),
(position?.toLong() ?: 0).coerceAtLeast(0), args["autoplay"] != false,
external, orientation, fit, speed, args["subtitleEnabled"] != false,
(args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }?.let { source(it, roots) },
args["hardwareAcceleration"] != false, args["pipEnabled"] != false)
}
}
}
/** Main-thread owned, process-retained rendezvous. Activity recreation does not
* resolve the Flutter call. Stale/duplicate activity results cannot consume a
* newer request. Clear before invoking callback to make reentrancy safe. */
class NativePlayerResultGate {
private data class Pending(val id: String, val reply: (Map<String, Any>) -> Unit,
var position: Long, var duration: Long = 0, var opened: Boolean = false, var completed: Boolean = false)
private var pending: Pending? = null
val activeId: String? get() = pending?.id
fun begin(id: String, positionMs: Long, reply: (Map<String, Any>) -> Unit): Boolean {
if (pending != null) return false
pending = Pending(id, reply, positionMs.coerceIn(0, Long.MAX_VALUE / 1000L))
return true
}
fun update(id: String, positionMs: Long, durationMs: Long, opened: Boolean, completed: Boolean): Boolean {
val item = pending?.takeIf { it.id == id } ?: return false
// Flutter Duration converts milliseconds to signed 64-bit microseconds.
item.duration = durationMs.coerceIn(0, Long.MAX_VALUE / 1000L)
item.position = positionMs.coerceIn(0, Long.MAX_VALUE / 1000L).let { if (item.duration > 0) it.coerceAtMost(item.duration) else it }
item.opened = item.opened || opened
item.completed = item.opened && completed
return true
}
fun finish(id: String): Boolean {
val item = pending?.takeIf { it.id == id } ?: return false
pending = null
// A permanently detached Flutter engine may reject a reply. The slot is
// still consumed exactly once; never retain an Activity/messenger or crash
// Android teardown while trying to notify an isolate that no longer exists.
try {
item.reply(mapOf("positionMs" to item.position, "durationMs" to item.duration,
"opened" to item.opened, "completed" to item.completed))
} catch (_: RuntimeException) { }
return true
}
fun dispose() { activeId?.let { finish(it) } }
}
object NativePlayerRequests {
val results = NativePlayerResultGate()
const val REQUEST_CODE = 3047
const val REQUEST_ID = "reelbox.nativePlayerRequestId"
}
@@ -0,0 +1,48 @@
package org.reelbox.reelbox
import java.net.URI
import java.net.URLDecoder
import java.text.Normalizer
import java.util.Locale
/** Outgoing media/browser policy only; never applied to catalog API traffic. */
object NativeUrlPolicy {
private val forbiddenHosts = listOf("t.me", "telegram.me", "telegram.org", "telegra.ph", "telegram.dog")
private val forbiddenPath = Regex("(^|[/_.?&=\\-])(apk|apks|xapk|aab|update|updates|updater|version|versions|support|contact|news|shop|store|telegram|subscribe|subscription|payment|login|register|drm|license)([/_.?&=\\-]|$)")
private val formatting = Regex("[\\p{Cf}\\u064b-\\u065f\\u0670\\u06d6-\\u06ed]")
private val entities = Regex("&#(x[0-9a-f]+|[0-9]+);", RegexOption.IGNORE_CASE)
private fun decode(value: String): String {
var decoded = value
repeat(3) { decoded = URLDecoder.decode(decoded, "UTF-8") }
return entities.replace(decoded) { match ->
val token = match.groupValues[1]
val number = if (token.startsWith("x", true)) token.drop(1).toIntOrNull(16) else token.toIntOrNull()
if (number != null && Character.isValidCodePoint(number)) String(Character.toChars(number)) else ""
}.replace("&amp;", "&").replace("&nbsp;", " ")
}
private fun normal(value: String): String = Normalizer.normalize(decode(value), Normalizer.Form.NFKC)
.lowercase(Locale.ROOT).replace('ي', 'ی').replace('ى', 'ی').replace('ك', 'ک').replace(formatting, "")
fun allowsRemote(value: String): Boolean = try {
val uri = URI(value)
val scheme = uri.scheme?.lowercase(Locale.ROOT)
val host = uri.host?.lowercase(Locale.ROOT)?.trimEnd('.') ?: ""
if (scheme !in listOf("http", "https") || host.isBlank() || uri.rawUserInfo != null || value.any { it.code <= 32 || it.code == 127 }) false
else {
val decoded = normal(value)
val compact = decoded.replace(Regex("[\\s_\\-\\u0640]"), "")
val vendor = listOf("melodybox", "melodibox", "norton", "ملودیباکس", "ملودیبکس", "نورتون", "نورتن").any { compact.contains(it) }
!vendor && !decoded.contains('\\') && !decoded.contains("تلگرام") &&
listOf("tg:", "telegram:", "intent:", "javascript:", "data:", "file:").none { decoded.contains(it) } &&
forbiddenHosts.none { host == it || host.endsWith(".$it") || decoded.contains("//$it/") || decoded.endsWith("//$it") } &&
!forbiddenPath.containsMatchIn(normal(uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty())) &&
!Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)([?#/]|$)").containsMatchIn(decoded)
}
} catch (_: Exception) { false }
fun allowsSharedFilename(value: String): Boolean = try {
!Regex("\\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)$").containsMatchIn(normal(value).trim().trimEnd('.'))
} catch (_: Exception) { false }
fun safePackage(value: String): Boolean = Regex("[A-Za-z][A-Za-z0-9_]*(\\.[A-Za-z][A-Za-z0-9_]*)+").matches(value)
}
@@ -0,0 +1,20 @@
package org.reelbox.reelbox
/** Decoder truth supplied by Dart, never inferred by a native timer/player. */
data class PlaybackSnapshot(
val title: String = "Reelbox", val playing: Boolean = false,
val positionMs: Long = 0, val durationMs: Long = 0
) {
companion object {
fun from(args: Map<*, *>, previous: PlaybackSnapshot = PlaybackSnapshot()): PlaybackSnapshot {
val duration = ((args["durationMs"] as? Number)?.toLong() ?: previous.durationMs).coerceAtLeast(0)
val position = ((args["positionMs"] as? Number)?.toLong() ?: previous.positionMs).coerceAtLeast(0)
return PlaybackSnapshot(
(args["title"] as? String ?: previous.title).take(512),
args["playing"] as? Boolean ?: previous.playing,
if (duration > 0) position.coerceAtMost(duration) else position,
duration
)
}
}
}
@@ -0,0 +1,128 @@
package org.reelbox.reelbox
/** Distinguishes asynchronous dismissal callbacks from replaced native sheets. */
class PlayerMenuSession {
private var current: Any? = null
fun open(): Any = Any().also { current = it }
fun close(owner: Any): Boolean {
if (current !== owner) return false
current = null
return true
}
}
/** Timeline conversion shared by native controls and relative seek callbacks. */
object PlayerSeekMath {
fun fromProgress(durationMs: Long, progress: Int): Long {
val duration = durationMs.coerceAtLeast(0)
val fraction = progress.coerceIn(0, 10000).toLong()
// Each product is bounded independently; duration * fraction may overflow.
return (duration / 10000L) * fraction + (duration % 10000L) * fraction / 10000L
}
fun relative(positionMs: Long, deltaMs: Long): Long {
val position = positionMs.coerceAtLeast(0)
return if (deltaMs > 0 && position > Long.MAX_VALUE - deltaMs) Long.MAX_VALUE
else (position + deltaMs).coerceAtLeast(0)
}
}
/**
* Deterministic chrome policy. No Android dependency, wall clock, or timer.
* The view supplies monotonic milliseconds and schedules only [deadlineMs].
* Playback snapshots do not count as interaction and cannot extend a deadline.
*/
class PlayerControlsState {
private var visible = true
private var playing = false
private var loading = false
private var error = false
private var pip = false
private var inForeground = true
private var seeking = false
private var menuOpen = false
private var released = false
var locked = false
private set
val chromeVisible: Boolean get() = visible && !pip && !locked && !released
val unlockVisible: Boolean get() = locked && !pip && !released
var deadlineMs: Long? = null
private set
fun playback(playing: Boolean, loading: Boolean, error: Boolean, pip: Boolean, now: Long) {
if (released) return
val exitingPip = this.pip && !pip
val newError = error && !this.error
this.playing = playing
this.loading = loading
this.error = error
this.pip = pip
if (exitingPip || newError) visible = true
if (newError) { locked = false; seeking = false; menuOpen = false }
schedule(now)
}
fun tap(now: Long) {
if (released || pip || locked || error || seeking || menuOpen) return
visible = !visible
deadlineMs = null
schedule(now)
}
fun interact(now: Long) {
if (released || pip || locked) return
visible = true
deadlineMs = null
schedule(now)
}
fun menu(open: Boolean, now: Long) {
if (released || menuOpen == open) return
menuOpen = open
if (open) visible = true
deadlineMs = null
schedule(now)
}
fun seeking(active: Boolean, now: Long) {
if (released || seeking == active) return
seeking = active
if (active) visible = true
deadlineMs = null
schedule(now)
}
fun lock(locked: Boolean, now: Long) {
if (released || pip || error) return
this.locked = locked
visible = !locked
deadlineMs = null
schedule(now)
}
fun foreground(foreground: Boolean, now: Long) {
if (released || inForeground == foreground) return
inForeground = foreground
deadlineMs = null
schedule(now)
}
fun tick(now: Long) {
if (!released && deadlineMs?.let { now >= it } == true) {
visible = false
deadlineMs = null
}
}
fun release() {
released = true
visible = false
deadlineMs = null
}
private fun schedule(now: Long) {
if (!playing || loading || error || pip || locked || seeking || menuOpen || !inForeground || !chromeVisible) {
deadlineMs = null
} else if (deadlineMs == null) deadlineMs = now + 3000L
}
}
@@ -0,0 +1,617 @@
package org.reelbox.reelbox
import android.app.Dialog
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorFilter
import android.graphics.Paint
import android.graphics.Path
import android.graphics.PixelFormat
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.graphics.drawable.ClipDrawable
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.RippleDrawable
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.text.TextUtils
import android.view.GestureDetector
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.ScrollView
import android.widget.SeekBar
import android.widget.TextView
import java.util.Locale
/** Immutable engine snapshot. Repeated snapshots are not user interactions. */
data class PlayerOverlayState(
val title: String,
val positionMs: Long = 0L,
val durationMs: Long = 0L,
val playing: Boolean = false,
val loading: Boolean = false,
val error: Boolean = false,
val seekable: Boolean = false,
val speed: Float = 1f,
val pip: Boolean = false,
)
/** Cinema chrome only: no decoder, URL, history, or activity ownership. */
class PlayerOverlayView(context: Context, private val callbacks: Callbacks) : FrameLayout(context) {
interface Callbacks {
fun onClose()
fun onTogglePlayback()
fun onSeekRelative(deltaMs: Long)
fun onSeekTo(positionMs: Long)
fun onSettings()
fun onExternal()
fun onRetry(software: Boolean)
fun onPictureInPicture()
}
private val handler = Handler(Looper.getMainLooper())
private val controls = PlayerControlsState()
private var state = PlayerOverlayState("")
private var released = false
private var dragging = false
private var cancellingDrag = false
private var suppressCancelledTouch = false
private var scheduledDeadline: Long? = null
private val safeInsets = android.graphics.Rect()
private val header = LinearLayout(context)
private val gestures = View(context).apply {
tag = "player-video-gestures"
contentDescription = "Video. Tap for controls; double-tap left or right to seek ten seconds"
isClickable = true
isFocusable = true
}
private val chrome = LinearLayout(context).apply {
tag = "player-controls"
orientation = LinearLayout.VERTICAL
clipChildren = true
}
private val title = label(16f).apply {
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
}
private val play = icon("play", "Play", "player-play") { callbacks.onTogglePlayback() }
private val back = icon("back", "Close player", "player-back") { callbacks.onClose() }
private val external = icon("external", "Open external player", "player-external") { callbacks.onExternal() }
private val settings = icon("settings", "Player settings", "player-settings") { callbacks.onSettings() }
private val lock = icon("lock", "Lock screen controls", "player-lock") {
controls.lock(true, now()); updateVisibility()
}
private val unlock = icon("unlock", "Unlock screen controls", "player-unlock") {
controls.lock(false, now()); updateVisibility()
}
private val rewind = icon("rewind", "Rewind ten seconds", "player-rewind") { callbacks.onSeekRelative(-10000L) }
private val forward = icon("forward", "Forward ten seconds", "player-forward") { callbacks.onSeekRelative(10000L) }
private val pip = icon("pip", "Picture in picture", "player-pip") { callbacks.onPictureInPicture() }
private val elapsed = label(12f).apply { tag = "player-clock"; gravity = Gravity.CENTER }
private val remaining = label(12f).apply { tag = "player-remaining"; gravity = Gravity.CENTER }
private val speed = label(12f).apply { gravity = Gravity.CENTER_VERTICAL; setTextColor(0xFFD0D0D0.toInt()) }
private val seek = SeekBar(context).apply {
tag = "player-seek"
contentDescription = "Seek video"
max = 10000
progressTintList = ColorStateList.valueOf(CORAL)
progressBackgroundTintList = ColorStateList.valueOf(0xFF666666.toInt())
thumbTintList = ColorStateList.valueOf(Color.WHITE)
splitTrack = false
minimumHeight = dp(48)
// Keep a 48dp touch area and a 2dp track, without API-29 min/max
// ProgressBar setters (the player also supports Android 24).
progressDrawable = LayerDrawable(arrayOf(
playerSurface(0xFF666666.toInt(), dp(1).toFloat()),
ClipDrawable(playerSurface(CORAL, dp(1).toFloat()), Gravity.LEFT, ClipDrawable.HORIZONTAL),
)).apply {
setId(0, android.R.id.background); setId(1, android.R.id.progress)
for (layer in 0..1) { setLayerHeight(layer, dp(2)); setLayerGravity(layer, Gravity.CENTER_VERTICAL) }
}
thumb = GradientDrawable().apply {
shape = GradientDrawable.OVAL; setColor(Color.WHITE); setSize(dp(10), dp(10))
}
}
private val loading = ProgressBar(context).apply {
tag = "player-loading"
contentDescription = "Buffering video"
indeterminateTintList = ColorStateList.valueOf(Color.WHITE)
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
}
private val errorPanel = LinearLayout(context).apply {
tag = "player-error"
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(16), dp(20), dp(12))
background = playerSurface(0xF0242424.toInt(), dp(16).toFloat())
}
private val errorScroll = ScrollView(context).apply { isFillViewport = false; addView(errorPanel) }
private val center = FrameLayout(context)
private val transport = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
private val bottom = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
private val feedback = label(14f).apply {
gravity = Gravity.CENTER
setPadding(dp(12), dp(8), dp(12), dp(8))
background = playerSurface(0xD0242424.toInt(), dp(16).toFloat())
visibility = View.GONE
}
private val hideFeedback = Runnable { if (!released) feedback.visibility = View.GONE }
private val hide = Runnable {
scheduledDeadline = null
if (!released) { controls.tick(now()); updateVisibility() }
}
private val detector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(event: MotionEvent) = true
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
if (!released) gestures.performClick()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
if (!released && !state.pip && !controls.locked && !state.error && state.seekable) {
val delta = if (event.x < gestures.width / 2f) -10000L else 10000L
controls.interact(now())
callbacks.onSeekRelative(delta)
if (released) return true
feedback.text = if (delta < 0) "−10 s" else "+10 s"
(feedback.layoutParams as LayoutParams).apply {
gravity = Gravity.CENTER_VERTICAL or if (delta < 0) Gravity.LEFT else Gravity.RIGHT
leftMargin = dp(24); rightMargin = dp(24)
feedback.layoutParams = this
}
feedback.visibility = View.VISIBLE
handler.removeCallbacks(hideFeedback)
handler.postDelayed(hideFeedback, 700)
updateVisibility()
}
return true
}
}, handler).apply { setIsLongpressEnabled(false) }
init {
// The only gesture detector is a sibling BELOW chrome. Buttons receive
// ACTION_UP immediately; they never wait for double-tap confirmation.
addView(gestures, LayoutParams(-1, -1))
addView(chrome, LayoutParams(-1, -1))
header.apply {
gravity = Gravity.CENTER_VERTICAL
background = scrim(true)
addView(back, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(title, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(View(context), LinearLayout.LayoutParams(dp(32), dp(48)))
addView(external, LinearLayout.LayoutParams(dp(48), dp(48)))
}
chrome.addView(header, LinearLayout.LayoutParams(-1, dp(48)))
transport.addView(rewind, LinearLayout.LayoutParams(dp(48), dp(48)))
transport.addView(play, LinearLayout.LayoutParams(dp(64), dp(64)).apply {
leftMargin = dp(20); rightMargin = dp(20)
})
transport.addView(forward, LinearLayout.LayoutParams(dp(48), dp(48)))
play.background = playerSurface(0xB8242424.toInt(), dp(32).toFloat())
center.addView(transport, LayoutParams(-2, -2, Gravity.CENTER))
chrome.addView(center, LinearLayout.LayoutParams(-1, 0, 1f))
bottom.background = scrim(false)
val timeline = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
addView(elapsed, LinearLayout.LayoutParams(dp(60), dp(48)))
addView(seek, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(remaining, LinearLayout.LayoutParams(dp(68), dp(48)))
}
bottom.addView(timeline, LinearLayout.LayoutParams(-1, dp(48)))
val footer = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
addView(lock, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(speed, LinearLayout.LayoutParams(0, dp(48), 1f))
addView(pip, LinearLayout.LayoutParams(dp(48), dp(48)))
addView(settings, LinearLayout.LayoutParams(dp(48), dp(48)))
}
bottom.addView(footer, LinearLayout.LayoutParams(-1, dp(48)))
chrome.addView(bottom, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(label(18f).apply {
text = "Unable to play this video"
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
}, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(label(14f).apply {
text = "Check your connection and retry, or open this source in another player."
setTextColor(0xFFCCCCCC.toInt())
setPadding(0, dp(8), 0, dp(12))
}, LinearLayout.LayoutParams(-1, -2))
val recovery = LinearLayout(context)
recovery.addView(action("Retry", "player-retry") { callbacks.onRetry(false) },
LinearLayout.LayoutParams(0, dp(48), 1f).apply { rightMargin = dp(8) })
recovery.addView(action("External player", "player-error-external") { callbacks.onExternal() },
LinearLayout.LayoutParams(0, dp(48), 1f))
errorPanel.addView(recovery, LinearLayout.LayoutParams(-1, -2))
errorPanel.addView(action("Try software decoding", "player-retry-software", subtle = true) {
callbacks.onRetry(true)
}, LinearLayout.LayoutParams(-1, dp(48)))
addView(errorScroll, LayoutParams(-1, -2, Gravity.CENTER).apply {
leftMargin = dp(20); rightMargin = dp(20)
topMargin = dp(56); bottomMargin = dp(8)
})
addView(loading, LayoutParams(dp(24), dp(24), Gravity.CENTER))
addView(unlock, LayoutParams(dp(48), dp(48), Gravity.RIGHT or Gravity.CENTER_VERTICAL).apply {
rightMargin = dp(16)
})
unlock.background = playerSurface(0xB8242424.toInt(), dp(24).toFloat())
addView(feedback, LayoutParams(-2, -2, Gravity.CENTER))
gestures.setOnClickListener {
if (!released) { controls.tap(now()); updateVisibility() }
}
gestures.setOnTouchListener { _, event -> detector.onTouchEvent(event); true }
seek.setOnTouchListener { _, event ->
when {
released -> true
event.actionMasked == MotionEvent.ACTION_DOWN -> {
suppressCancelledTouch = false
false
}
suppressCancelledTouch -> true
event.actionMasked == MotionEvent.ACTION_CANCEL -> {
suppressCancelledTouch = true
cancelActiveScrub(); updateVisibility(); true
}
else -> false
}
}
seek.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onStartTrackingTouch(bar: SeekBar) {
if (released) return
dragging = true
controls.seeking(true, now()); updateVisibility()
}
override fun onProgressChanged(bar: SeekBar, progress: Int, fromUser: Boolean) {
if (released || !fromUser || !canSeek()) return
val target = PlayerSeekMath.fromProgress(state.durationMs, progress)
updateClock(target)
if (!dragging) {
// Keyboard/accessibility changes never send onStopTrackingTouch.
controls.interact(now())
callbacks.onSeekTo(target)
updateVisibility()
}
}
override fun onStopTrackingTouch(bar: SeekBar) {
if (released || !dragging) return
dragging = false
controls.seeking(false, now())
if (cancellingDrag) return
if (canSeek()) {
callbacks.onSeekTo(PlayerSeekMath.fromProgress(state.durationMs, bar.progress))
}
updateVisibility()
}
})
updateVisibility()
}
fun render(state: PlayerOverlayState) {
if (released) return
this.state = state
controls.playback(state.playing, state.loading, state.error, state.pip, now())
title.text = state.title.ifBlank { "ReelBox" }
play.setImageDrawable(PlayerIcon(if (state.playing) "pause" else "play", intrinsicSize = dp(28)))
play.contentDescription = if (state.playing) "Pause" else "Play"
play.isEnabled = !state.error
syncSeekEnabled()
rewind.isEnabled = state.seekable && !state.error; forward.isEnabled = rewind.isEnabled
rewind.alpha = if (rewind.isEnabled) 1f else .4f
forward.alpha = rewind.alpha
speed.text = "${state.speed.toString().removeSuffix(".0")}×"
if (!dragging) {
seek.progress = if (state.durationMs > 0)
(state.positionMs.toDouble() / state.durationMs * 10000).toInt().coerceIn(0, 10000) else 0
updateClock(state.positionMs)
}
updateVisibility()
}
fun setMenuOpen(open: Boolean) {
if (!released) { controls.menu(open, now()); updateVisibility() }
}
fun setForeground(foreground: Boolean) {
if (!released) { controls.foreground(foreground, now()); updateVisibility() }
}
/** Inset controls, not scrims: their backgrounds must reach every edge. */
fun setSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
if (released) return
if (safeInsets == android.graphics.Rect(left, top, right, bottom)) return
safeInsets.set(left, top, right, bottom)
header.setPadding(left, top, right, 0)
header.layoutParams = (header.layoutParams as LinearLayout.LayoutParams).apply { height = dp(48) + top }
this.bottom.setPadding(left, 0, right, bottom)
center.setPadding(left, 0, right, 0)
errorScroll.layoutParams = (errorScroll.layoutParams as LayoutParams).apply {
leftMargin = dp(20) + left; rightMargin = dp(20) + right
topMargin = dp(56) + top; bottomMargin = dp(8) + bottom
}
unlock.layoutParams = (unlock.layoutParams as LayoutParams).apply { rightMargin = dp(16) + right }
updateVisibility()
}
fun release() {
if (released) return
released = true
controls.release()
scheduledDeadline = null
handler.removeCallbacksAndMessages(null)
// GestureDetector owns an internal Handler; CANCEL clears its pending taps.
val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
detector.onTouchEvent(cancel)
cancel.recycle()
gestures.setOnTouchListener(null)
gestures.setOnClickListener(null)
seek.setOnSeekBarChangeListener(null)
// Freeze the last frame until the activity detaches; release must not
// flash a new chrome state or let a pending confirmed tap change it.
}
private fun updateClock(position: Long) {
elapsed.text = playerTime(position)
elapsed.contentDescription = "Elapsed ${elapsed.text}"
remaining.text = if (state.durationMs > 0) "−${playerTime((state.durationMs - position).coerceAtLeast(0))}" else "—:—"
remaining.contentDescription = if (state.durationMs > 0) "Remaining ${remaining.text}" else "Remaining time unknown"
}
private fun updateVisibility() {
if (released) return
syncSeekEnabled()
gestures.visibility = if (state.pip) View.GONE else View.VISIBLE
importantForAccessibility = if (state.pip) View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
else View.IMPORTANT_FOR_ACCESSIBILITY_AUTO
chrome.visibility = if (controls.chromeVisible && !state.pip) View.VISIBLE else View.GONE
unlock.visibility = if (controls.unlockVisible && !state.pip) View.VISIBLE else View.GONE
loading.visibility = if (state.loading && !state.error && !state.pip && !controls.locked) View.VISIBLE else View.GONE
errorScroll.visibility = if (state.error && !state.pip) View.VISIBLE else View.GONE
errorPanel.visibility = errorScroll.visibility
transport.visibility = if (state.error) View.GONE else View.VISIBLE
bottom.visibility = if (state.error) View.GONE else View.VISIBLE
if (state.pip || controls.locked || state.error) feedback.visibility = View.GONE
(loading.layoutParams as LayoutParams).apply {
gravity = if (controls.chromeVisible) Gravity.TOP or Gravity.RIGHT else Gravity.CENTER
topMargin = if (controls.chromeVisible) dp(12) + safeInsets.top else 0
rightMargin = if (controls.chromeVisible) dp(60) + safeInsets.right else 0
loading.layoutParams = this
}
if (scheduledDeadline != controls.deadlineMs) {
handler.removeCallbacks(hide)
scheduledDeadline = controls.deadlineMs
scheduledDeadline?.let { handler.postDelayed(hide, (it - now()).coerceAtLeast(0)) }
}
}
private fun canSeek() = state.seekable && state.durationMs > 0 && !state.error && !state.pip && !controls.locked
private fun syncSeekEnabled() {
if (!canSeek()) cancelActiveScrub()
seek.isEnabled = canSeek()
}
private fun cancelActiveScrub() {
if (!dragging || cancellingDrag) return
// Parent touch targets can outlive a disabled/hidden SeekBar. Consume
// the rest of this finger stream even if playback recovers before UP;
// only a fresh DOWN may rearm touch seeking. Non-touch input is separate.
suppressCancelledTouch = true
cancellingDrag = true
val cancel = MotionEvent.obtain(now(), now(), MotionEvent.ACTION_CANCEL, 0f, 0f, 0)
try {
// AbsSeekBar must see CANCEL while still enabled to clear its own
// tracking state. Its onStop callback is cancellation, never a seek.
seek.onTouchEvent(cancel)
} finally {
cancel.recycle()
seek.isPressed = false
dragging = false
controls.seeking(false, now())
cancellingDrag = false
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// Resolve responsive sizes before children measure, including inset-only
// changes where onSizeChanged would not run (rotation/PiP transitions).
val w = MeasureSpec.getSize(widthMeasureSpec)
val h = MeasureSpec.getSize(heightMeasureSpec)
val size = if (h - safeInsets.top - safeInsets.bottom < dp(240)) dp(48) else dp(64)
val playParams = play.layoutParams as LinearLayout.LayoutParams
if (playParams.width != size || playParams.height != size) {
play.layoutParams = playParams.apply { width = size; height = size }
}
val errorWidth = (w - safeInsets.left - safeInsets.right - dp(40)).coerceAtMost(dp(440)).coerceAtLeast(0)
val errorParams = errorScroll.layoutParams as LayoutParams
if (errorParams.width != errorWidth) errorScroll.layoutParams = errorParams.apply { width = errorWidth }
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
private fun icon(kind: String, description: String, viewTag: String, action: () -> Unit) = ImageView(context).apply {
tag = viewTag
contentDescription = description
setImageDrawable(PlayerIcon(kind, intrinsicSize = dp(24)))
scaleType = ImageView.ScaleType.CENTER_INSIDE
setPadding(dp(12), dp(12), dp(12), dp(12))
background = RippleDrawable(ColorStateList.valueOf(0x35FFFFFF), null, playerSurface(Color.WHITE, dp(24).toFloat()))
isFocusable = true
setOnClickListener {
if (!released && !state.pip && (!controls.locked || viewTag == "player-unlock")) {
controls.interact(now()); action(); if (!released) updateVisibility()
}
}
}
private fun action(text: String, viewTag: String, subtle: Boolean = false, action: () -> Unit) = label(if (subtle) 12f else 14f).apply {
tag = viewTag; this.text = text; contentDescription = text
gravity = Gravity.CENTER; maxLines = 2
setPadding(dp(8), dp(4), dp(8), dp(4))
setTextColor(if (subtle) 0xFFBDBDBD.toInt() else Color.WHITE)
if (!subtle) background = playerSurface(0xFF3A3A3A.toInt(), dp(12).toFloat())
isFocusable = true
setOnClickListener {
if (!released && !state.pip) {
controls.interact(now()); action(); if (!released) updateVisibility()
}
}
}
private fun label(size: Float) = TextView(context).apply {
textSize = size; setTextColor(Color.WHITE)
typeface = Typeface.create("sans-serif", Typeface.NORMAL)
includeFontPadding = false
}
private fun scrim(top: Boolean) = GradientDrawable(
if (top) GradientDrawable.Orientation.TOP_BOTTOM else GradientDrawable.Orientation.BOTTOM_TOP,
intArrayOf(0xC9000000.toInt(), 0x00000000),
)
private fun dp(value: Int) = (value * resources.displayMetrics.density + .5f).toInt()
private fun now() = SystemClock.uptimeMillis()
companion object { private val CORAL = Color.rgb(255, 130, 118) }
}
private fun playerTime(ms: Long): String {
val seconds = ms.coerceAtLeast(0) / 1000
return if (seconds >= 3600) String.format(Locale.ROOT, "%d:%02d:%02d", seconds / 3600, seconds / 60 % 60, seconds % 60)
else String.format(Locale.ROOT, "%d:%02d", seconds / 60, seconds % 60)
}
internal fun playerSurface(color: Int, radius: Float) = GradientDrawable().apply {
setColor(color); cornerRadius = radius
}
/** One consistent 24-unit, rounded 1.7-unit stroke family; no font glyph icons. */
internal class PlayerIcon(
private val kind: String,
private val tint: Int = Color.WHITE,
private val intrinsicSize: Int = 24,
) : Drawable() {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = tint; style = Paint.Style.STROKE; strokeWidth = 1.7f
strokeCap = Paint.Cap.ROUND; strokeJoin = Paint.Join.ROUND
}
override fun draw(canvas: Canvas) {
canvas.save()
canvas.translate(bounds.left.toFloat(), bounds.top.toFloat())
canvas.scale(bounds.width() / 24f, bounds.height() / 24f)
fun line(vararg coordinates: Float) {
val path = Path(); path.moveTo(coordinates[0], coordinates[1])
for (i in 2 until coordinates.size step 2) path.lineTo(coordinates[i], coordinates[i + 1])
canvas.drawPath(path, paint)
}
when (kind) {
"play" -> line(9f, 5f, 19f, 12f, 9f, 19f, 9f, 5f)
"pause" -> { line(8f, 5f, 8f, 19f); line(16f, 5f, 16f, 19f) }
"back" -> { line(14f, 5f, 7f, 12f, 14f, 19f); line(7f, 12f, 21f, 12f) }
"external" -> {
line(14f, 4f, 21f, 4f, 21f, 11f); line(21f, 4f, 11f, 14f)
line(10f, 5f, 4f, 5f, 4f, 20f, 19f, 20f, 19f, 14f)
}
"pip" -> { canvas.drawRoundRect(3f, 5f, 21f, 19f, 1f, 1f, paint); canvas.drawRect(12f, 11f, 18f, 16f, paint) }
"lock", "unlock" -> {
canvas.drawRoundRect(5f, 10f, 19f, 21f, 2f, 2f, paint)
if (kind == "lock") canvas.drawArc(8f, 2f, 16f, 13f, 180f, 180f, false, paint)
else canvas.drawArc(8f, 2f, 18f, 13f, 180f, 150f, false, paint)
line(12f, 14f, 12f, 17f)
}
"settings" -> {
line(4f, 7f, 20f, 7f); line(4f, 17f, 20f, 17f)
canvas.drawCircle(9f, 7f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() })
canvas.drawCircle(9f, 7f, 3f, paint)
canvas.drawCircle(15f, 17f, 3f, Paint(paint).apply { style = Paint.Style.FILL; color = 0xFF242424.toInt() })
canvas.drawCircle(15f, 17f, 3f, paint)
}
"rewind", "forward" -> {
canvas.save()
if (kind == "forward") { canvas.translate(24f, 0f); canvas.scale(-1f, 1f) }
canvas.drawArc(4f, 4f, 21f, 21f, -105f, 285f, false, paint)
line(4f, 3f, 4f, 9f, 10f, 9f)
canvas.restore()
// Numerals are paths, not device-dependent emoji/icon glyphs.
line(9f, 12f, 10f, 11f, 10f, 17f)
canvas.drawRoundRect(13f, 11f, 17f, 17f, 1.5f, 1.5f, paint)
}
"check" -> line(5f, 12f, 10f, 17f, 20f, 7f)
"close" -> { line(6f, 6f, 18f, 18f); line(18f, 6f, 6f, 18f) }
}
canvas.restore()
}
override fun getIntrinsicWidth() = intrinsicSize
override fun getIntrinsicHeight() = intrinsicSize
override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
override fun setColorFilter(filter: ColorFilter?) { paint.colorFilter = filter; invalidateSelf() }
@Suppress("OVERRIDE_DEPRECATION") override fun getOpacity() = PixelFormat.TRANSLUCENT
}
/** Bottom-anchored native sheet, scrollable at small landscape heights/font sizes. */
internal fun playerChoiceSheet(
context: Context, title: String, values: List<String>, selected: Int,
dismissed: () -> Unit, pick: (Int) -> Unit,
): Dialog {
fun dp(value: Int) = (value * context.resources.displayMetrics.density + .5f).toInt()
val dialog = Dialog(context)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
val panel = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(12), dp(8), dp(12), dp(12))
background = playerSurface(0xFF242424.toInt(), dp(20).toFloat())
}
val heading = LinearLayout(context).apply { gravity = Gravity.CENTER_VERTICAL }
heading.addView(TextView(context).apply {
text = title; textSize = 18f; setTextColor(Color.WHITE)
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
setPadding(dp(12), 0, dp(8), 0)
}, LinearLayout.LayoutParams(0, -2, 1f))
heading.addView(ImageView(context).apply {
contentDescription = "Close settings"
setImageDrawable(PlayerIcon("close", intrinsicSize = dp(24))); setPadding(dp(12), dp(12), dp(12), dp(12))
isFocusable = true; setOnClickListener { dialog.dismiss() }
}, LinearLayout.LayoutParams(dp(48), dp(48)))
panel.addView(heading, LinearLayout.LayoutParams(-1, -2))
val choices = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL }
values.forEachIndexed { index, value ->
val checked = index == selected
val row = LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
minimumHeight = dp(56)
setPadding(dp(12), dp(8), dp(12), dp(8))
background = playerSurface(if (checked) 0xFF333333.toInt() else 0xFF242424.toInt(), dp(12).toFloat())
contentDescription = "$value${if (checked) ", selected" else ""}"
isSelected = checked; isFocusable = true
setOnClickListener { dialog.dismiss(); pick(index) }
}
row.addView(TextView(context).apply {
text = value; textSize = 15f; setTextColor(Color.WHITE)
setPadding(0, 0, dp(12), 0)
}, LinearLayout.LayoutParams(0, -2, 1f))
row.addView(ImageView(context).apply {
if (checked) setImageDrawable(PlayerIcon("check", Color.rgb(255, 130, 118)))
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(dp(24), dp(24)))
choices.addView(row, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(4) })
}
panel.addView(ScrollView(context).apply { addView(choices) }, LinearLayout.LayoutParams(-1, 0, 1f))
dialog.setContentView(panel)
dialog.setOnDismissListener { dismissed() }
dialog.window?.apply {
setBackgroundDrawableResource(android.R.color.transparent)
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
setDimAmount(.55f)
setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL)
val metrics = context.resources.displayMetrics
val contentHeight = dp(76 + values.size * 60)
setLayout((metrics.widthPixels - dp(24)).coerceAtMost(dp(560)),
contentHeight.coerceAtMost((metrics.heightPixels * .82f).toInt()))
}
dialog.show()
return dialog
}
@@ -0,0 +1,173 @@
package org.reelbox.reelbox
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.mediarouter.app.MediaRouteButton
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.MediaLoadRequestData
import com.google.android.gms.cast.MediaMetadata
import com.google.android.gms.cast.MediaSeekOptions
import com.google.android.gms.cast.MediaTrack
import com.google.android.gms.cast.framework.CastButtonFactory
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.CastSession
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionManagerListener
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.google.android.gms.common.api.PendingResult
import io.flutter.plugin.common.MethodChannel
import java.util.concurrent.TimeUnit
/** Default Media Receiver: no server credentials, DRM keys, or custom receiver app. */
class ReelboxCastOptions : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions = CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.setCastMediaOptions(CastMediaOptions.Builder().setNotificationOptions(null).setMediaSessionEnabled(false).build())
.setEnableReconnectionService(false)
.build()
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
}
/** Support-fragment host kept separate from the Flutter activity/theme. */
class CastPickerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
title = "Cast video"
val button = MediaRouteButton(this)
button.contentDescription = "Choose a Cast receiver"
setContentView(button, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
try {
CastButtonFactory.setUpMediaRouteButton(this, button)
if (savedInstanceState == null) button.post { button.showDialog() }
} catch (_: Exception) {
android.widget.Toast.makeText(this, "Google Cast is unavailable on this device.", android.widget.Toast.LENGTH_LONG).show()
finish()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
}
override fun onSupportNavigateUp(): Boolean { finish(); return true }
}
class ReelboxCast(private val activity: MainActivity, private val channel: MethodChannel) {
private var context: CastContext? = null
private val handler = Handler(Looper.getMainLooper())
private var disposed = false
private val poll = object : Runnable {
override fun run() {
if (disposed) return
emit()
handler.postDelayed(this, 1000)
}
}
private val listener = object : SessionManagerListener<CastSession> {
override fun onSessionStarting(session: CastSession) = emit()
override fun onSessionStarted(session: CastSession, id: String) = emit()
override fun onSessionStartFailed(session: CastSession, error: Int) = emit()
override fun onSessionEnding(session: CastSession) = emit()
override fun onSessionEnded(session: CastSession, error: Int) = emit()
override fun onSessionResuming(session: CastSession, id: String) = emit()
override fun onSessionResumed(session: CastSession, suspended: Boolean) = emit()
override fun onSessionResumeFailed(session: CastSession, error: Int) = emit()
override fun onSessionSuspended(session: CastSession, reason: Int) = emit()
}
@Suppress("DEPRECATION")
private fun initialize(): CastContext {
check(!disposed)
return context ?: CastContext.getSharedInstance(activity).also {
context = it
it.sessionManager.addSessionManagerListener(listener, CastSession::class.java)
handler.post(poll)
}
}
fun state(): Map<String, Any> {
val session = context?.sessionManager?.currentCastSession
val connected = session?.isConnected == true
val client = if (connected) session?.remoteMediaClient else null
return mapOf(
"connected" to connected,
"playing" to (client?.isPlaying == true),
"positionMs" to (client?.approximateStreamPosition ?: 0L),
"durationMs" to (client?.streamDuration ?: 0L),
"deviceName" to (session?.castDevice?.friendlyName ?: "")
)
}
private fun emit() { if (!disposed) channel.invokeMethod("castChanged", state()) }
fun showPicker(): Boolean {
initialize()
activity.startActivity(Intent(activity, CastPickerActivity::class.java))
return true
}
fun getState(): Map<String, Any> { initialize(); return state() }
private fun remote(): RemoteMediaClient = initialize().sessionManager.currentCastSession
?.takeIf { it.isConnected }?.remoteMediaClient
?: throw IllegalStateException("No Cast receiver is connected.")
fun load(args: Map<*, *>, result: MethodChannel.Result) {
val url = args["url"] as? String ?: ""
require(NativeUrlPolicy.allowsRemote(url)) { "Unsupported Cast URL." }
val subtitle = (args["subtitleUrl"] as? String)?.takeIf { it.isNotBlank() }
require(subtitle == null || NativeUrlPolicy.allowsRemote(subtitle)) { "Unsupported subtitle URL." }
val metadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE).apply {
putString(MediaMetadata.KEY_TITLE, (args["title"] as? String).orEmpty().take(512))
}
val contentType = (args["contentType"] as? String)?.takeIf { it.isNotBlank() } ?: when {
android.net.Uri.parse(url).path.orEmpty().endsWith(".m3u8", true) -> "application/x-mpegURL"
android.net.Uri.parse(url).path.orEmpty().endsWith(".mpd", true) -> "application/dash+xml"
else -> "video/mp4"
}
val info = MediaInfo.Builder(url).setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setContentType(contentType).setMetadata(metadata)
if (subtitle != null) info.setMediaTracks(listOf(MediaTrack.Builder(1, MediaTrack.TYPE_TEXT)
.setContentId(subtitle).setContentType("text/vtt").setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
.setName("Subtitles").build()))
val request = MediaLoadRequestData.Builder().setMediaInfo(info.build()).setAutoplay(true)
.setCurrentTime((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0)
if (subtitle != null) request.setActiveTrackIds(longArrayOf(1))
complete(remote().load(request.build()), result)
}
fun command(args: Map<*, *>, result: MethodChannel.Result) {
val client = remote()
val request = when (args["action"] as? String) {
"play" -> client.play()
"pause" -> client.pause()
"stop" -> client.stop()
"seek" -> client.seek(MediaSeekOptions.Builder()
.setPosition((args["positionMs"] as? Number)?.toLong()?.coerceAtLeast(0) ?: 0).build())
"volume" -> client.setStreamVolume((args["value"] as? Number)?.toDouble()?.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0) ?: 1.0)
else -> throw IllegalArgumentException("Unknown Cast command.")
}
complete(request, result)
}
private fun complete(request: PendingResult<RemoteMediaClient.MediaChannelResult>, result: MethodChannel.Result) {
request.setResultCallback({ response ->
if (response.status.isSuccess) result.success(true)
else result.error("CAST_RECEIVER", "The Cast receiver rejected or timed out this operation.", response.status.statusCode)
emit()
}, 20, TimeUnit.SECONDS)
}
fun disconnect() { initialize().sessionManager.endCurrentSession(true); emit() }
fun dispose() {
disposed = true
handler.removeCallbacksAndMessages(null)
context?.sessionManager?.removeSessionManagerListener(listener, CastSession::class.java)
context = null
}
}
@@ -0,0 +1,116 @@
package org.reelbox.reelbox
import java.io.ByteArrayInputStream
import java.io.FilterInputStream
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URI
/** Credential-free HTTP transport: the WebView must never own HTTP redirects. */
internal class RestrictedResourceFetcher(
private val openConnection: (java.net.URL) -> HttpURLConnection = { it.openConnection() as HttpURLConnection },
) {
data class Response(
val statusCode: Int,
val body: InputStream,
val mimeType: String = "text/plain",
val encoding: String? = "UTF-8",
val reasonPhrase: String = "Blocked",
val headers: Map<String, String> = emptyMap(),
val effectiveUrl: String = "",
)
fun fetch(
url: String,
method: String = "GET",
requestHeaders: Map<String, String> = emptyMap(),
secureContext: Boolean = false,
): Response {
if (method != "GET" && method != "HEAD") return empty(405)
if (!allowsResource(url)) return empty(403)
var target = URI(url).normalize()
var requireTls = secureContext || target.scheme.equals("https", true)
var forwardContext = true
var active: HttpURLConnection? = null
try {
repeat(6) { hop ->
if (!allowsResource(target.toString()) ||
(requireTls && !target.scheme.equals("https", true))) return empty(403)
requireTls = requireTls || target.scheme.equals("https", true)
val connection = openConnection(target.toURL())
active = connection
connection.connectTimeout = 10_000
connection.readTimeout = 15_000
connection.allowUserInteraction = false
connection.instanceFollowRedirects = false
connection.requestMethod = method
connection.useCaches = false
// A small public-resource allowlist, not arbitrary headers or a cookie jar.
for ((name, value) in requestHeaders) {
val lower = name.lowercase(java.util.Locale.ROOT)
val allowedHeader = lower in setOf("accept", "accept-language", "user-agent", "range", "if-range") ||
(forwardContext && lower in setOf("origin", "referer"))
if (allowedHeader && value.none { it == '\r' || it == '\n' || it == '\u0000' }) {
connection.setRequestProperty(name, value)
}
}
// Keep payload bytes and Content-Length/Range/Encoding consistent; no transparent gzip.
connection.setRequestProperty("Accept-Encoding", "identity")
val status = connection.responseCode
if (status in listOf(301, 302, 303, 307, 308)) {
val location = connection.getHeaderField("Location")
connection.disconnect()
active = null
if (location.isNullOrBlank()) return empty(502)
if (hop == 5) return empty(508)
val next = target.resolve(location).normalize()
fun port(uri: URI) = if (uri.port != -1) uri.port else if (uri.scheme.equals("https", true)) 443 else 80
if (!target.scheme.equals(next.scheme, true) || !target.host.equals(next.host, true) || port(target) != port(next)) {
forwardContext = false // Never restore a stripped context on a later hop back.
}
target = next
} else {
// Android rejects all 3xx WebResourceResponse statuses, including 304.
if (status !in 100..299 && status !in 400..599) return empty(502)
val stream = if (method == "HEAD") null else if (status >= 400) connection.errorStream else connection.inputStream
val contentType = connection.contentType.orEmpty().split(';')
val mime = contentType.first().trim().ifEmpty { "application/octet-stream" }
val charset = contentType.drop(1).firstOrNull { it.trim().startsWith("charset=", true) }
?.substringAfter('=')?.trim()?.trim('"', '\'')
val hopHeaders = connection.getHeaderField("Connection").orEmpty().split(',')
.map { it.trim().lowercase(java.util.Locale.ROOT) }.toSet()
val excluded = hopHeaders + setOf("connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailer", "transfer-encoding", "upgrade", "set-cookie", "set-cookie2")
val headers = connection.headerFields.filterKeys { it != null && it.lowercase(java.util.Locale.ROOT) !in excluded }
.mapValues { (_, values) -> values.joinToString(", ") }
val body = object : FilterInputStream(stream ?: ByteArrayInputStream(ByteArray(0))) {
override fun close() { try { super.close() } finally { connection.disconnect() } }
}
val reason = connection.responseMessage.orEmpty()
.takeIf { it.isNotBlank() && it.all { char -> char.code in 32..126 } } ?: "HTTP response"
active = null // Body owns the connection until WebView closes it.
return Response(status, body, mime, charset, reason, headers, target.toString())
}
}
} catch (_: Exception) {
return empty(502)
} finally { active?.disconnect() }
return empty(508)
}
private fun allowsResource(url: String): Boolean = try {
if (!NativeUrlPolicy.allowsRemote(url)) false else {
val uri = URI(url)
var route = uri.rawPath.orEmpty() + "?" + uri.rawQuery.orEmpty()
repeat(3) { route = java.net.URLDecoder.decode(route, "UTF-8") }
route = java.text.Normalizer.normalize(route, java.text.Normalizer.Form.NFKC)
.lowercase(java.util.Locale.ROOT).replace(Regex("[\\p{Cf}]"), "")
// Removed catalog families are not embed resources. Keep private/episode playlists.
!Regex("(^|[/_.?&=\\-])(live|channels?|live[-_]?channels?|public[-_]?(play)?lists?)([/_.?&=\\-]|$)")
.containsMatchIn(route) &&
!Regex("[?&]action=(showlist|show-list_fav|playlist-items)([&#]|$)").containsMatchIn(route)
}
} catch (_: Exception) { false }
private fun empty(status: Int) = Response(status, ByteArrayInputStream(ByteArray(0)))
}
@@ -0,0 +1,643 @@
package org.reelbox.reelbox
import android.app.Activity
import android.app.Dialog
import android.app.PictureInPictureParams
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Color
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Rational
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.Toast
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
import java.io.File
import java.util.concurrent.Executors
/** Embedded LGPL LibVLC; never launches proprietary KMPlayer or rewrites source
* hosts. APIs verified against libvlc-all 3.7.7 sources and classes.jar on Maven
* Central. LibVLC owns asynchronous network I/O and normal TLS/auth validation.
* No network fetch, trust-manager override or media proxy exists in this view.
*
* Background policy is deliberately pause, not an unowned background decoder.
* PiP retains the video/decoder; closing the view releases it on a worker.
*/
@Suppress("DEPRECATION") // API 24 compatibility branches alongside modern APIs.
class VlcPlayerActivity : Activity() {
private val main = Handler(Looper.getMainLooper())
private val teardown = Executors.newSingleThreadExecutor()
private var vlc: LibVLC? = null
private var player: MediaPlayer? = null
private lateinit var launch: NativePlayerLaunch
private lateinit var surface: VLCVideoLayout
private lateinit var root: FrameLayout
private lateinit var overlay: PlayerOverlayView
private var requestId = ""
private var positionMs = 0L
private var durationMs = 0L
private var opened = false
private var completed = false
private var desiredPlaying = true
private var foreground = false
private var closed = false
private var loading = false
private var releasing = false
private var reloadRequested = false
private var error = false
private var pendingSeek: Long? = null
private var seekWaitTicks = 0
private var speed = 1f
private var fit = "contain"
private var hardware = true
private var subtitles = true
private var audioTrackId: Int? = null
private var subtitleTrackId: Int? = null
private var originalSystemUi = 0
private var originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
private lateinit var audio: AudioManager
private var focusRequest: AudioFocusRequest? = null
private var hasFocus = false
private var resumeAfterFocus = false
private var receiverRegistered = false
private var dialog: Dialog? = null
private val menuSession = PlayerMenuSession()
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
main.post {
if (!closed) when (change) {
AudioManager.AUDIOFOCUS_GAIN -> {
hasFocus = true
player?.setVolume(100)
if (resumeAfterFocus && (foreground || inPip()) && !error) {
resumeAfterFocus = false
desiredPlaying = true
player?.play()
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> player?.setVolume(25)
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, AudioManager.AUDIOFOCUS_LOSS -> {
resumeAfterFocus = change == AudioManager.AUDIOFOCUS_LOSS_TRANSIENT && desiredPlaying
hasFocus = false
if (change == AudioManager.AUDIOFOCUS_LOSS) desiredPlaying = false
player?.pause()
render()
}
}
}
}
private val noisy = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) pauseByUser()
}
}
private val tick = object : Runnable {
override fun run() {
if (closed) return
if (foreground || inPip()) {
applyPendingSeek()
snapshot()
render()
}
main.postDelayed(this, 500)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
originalSystemUi = window.decorView.systemUiVisibility
originalOrientation = requestedOrientation
requestId = intent.getStringExtra(NativePlayerRequests.REQUEST_ID).orEmpty()
// Even a failed/recreated launch has an identified cancellation result.
setResult(RESULT_CANCELED, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId))
try {
val extras = intent.extras
val args = extras?.keySet()?.associateWith { extras.get(it) }.orEmpty()
launch = NativePlayerLaunch.from(args, ownedRoots(this))
} catch (_: Exception) {
finish()
return
}
positionMs = savedInstanceState?.getLong("positionMs") ?: launch.positionMs
durationMs = savedInstanceState?.getLong("durationMs") ?: 0
opened = savedInstanceState?.getBoolean("opened") ?: false
desiredPlaying = savedInstanceState?.getBoolean("playing") ?: launch.autoplay
speed = savedInstanceState?.getFloat("speed") ?: launch.speed
fit = savedInstanceState?.getString("fit") ?: launch.videoFit
hardware = savedInstanceState?.getBoolean("hardware") ?: launch.hardwareAcceleration
subtitles = savedInstanceState?.getBoolean("subtitles") ?: launch.subtitleEnabled
if (savedInstanceState?.containsKey("audioTrack") == true) audioTrackId = savedInstanceState.getInt("audioTrack")
if (savedInstanceState?.containsKey("subtitleTrack") == true) subtitleTrackId = savedInstanceState.getInt("subtitleTrack")
audio = getSystemService(AUDIO_SERVICE) as AudioManager
volumeControlStream = AudioManager.STREAM_MUSIC
if (Build.VERSION.SDK_INT >= 26) {
focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE).build())
.setOnAudioFocusChangeListener(focusListener, main).build()
}
if (Build.VERSION.SDK_INT >= 33) registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), RECEIVER_NOT_EXPORTED)
else registerReceiver(noisy, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY))
receiverRegistered = true
requestedOrientation = when (launch.orientation) {
"landscape" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
"portrait" -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT
else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
buildUi()
configureWindow()
publish()
main.post(tick)
load()
}
private fun buildUi() {
root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
surface = VLCVideoLayout(this)
root.addView(surface, FrameLayout.LayoutParams(-1, -1))
overlay = PlayerOverlayView(this, object : PlayerOverlayView.Callbacks {
override fun onClose() { finish() }
override fun onTogglePlayback() { togglePlay() }
override fun onSeekRelative(deltaMs: Long) { seekTo(PlayerSeekMath.relative(positionMs, deltaMs)) }
override fun onSeekTo(positionMs: Long) { seekTo(positionMs) }
override fun onSettings() { showSettings() }
override fun onExternal() { external() }
override fun onRetry(software: Boolean) { retry(software) }
override fun onPictureInPicture() {
if (!enterPip()) toast("Picture-in-picture is unavailable")
}
})
root.addView(overlay, FrameLayout.LayoutParams(-1, -1))
setContentView(root)
root.setOnApplyWindowInsetsListener { _, insets ->
// Insets never pad the video. Only the sibling chrome avoids bars.
if (inPip()) overlay.setSafeInsets(0, 0, 0, 0)
else if (Build.VERSION.SDK_INT >= 30) {
val bars = insets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout())
overlay.setSafeInsets(bars.left, bars.top, bars.right, bars.bottom)
} else overlay.setSafeInsets(insets.systemWindowInsetLeft, insets.systemWindowInsetTop,
insets.systemWindowInsetRight, insets.systemWindowInsetBottom)
insets
}
render()
}
@Suppress("DEPRECATION")
private fun configureWindow() {
window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
if (Build.VERSION.SDK_INT >= 28) window.attributes = window.attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
root.requestApplyInsets()
}
private fun load() {
if (closed || !::launch.isInitialized) return
if (releasing) { reloadRequested = true; return }
error = false
loading = true
completed = false
pendingSeek = positionMs.takeIf { it > 0 }
seekWaitTicks = 0
render()
try {
val engine = LibVLC(applicationContext)
vlc = engine
val current = MediaPlayer(engine)
player = current
current.attachViews(surface, null, true, false)
applyFit()
current.setVideoTitleDisplay(MediaPlayer.Position.Disable, 0)
current.setEventListener { event ->
if (!closed && player === current) handleEvent(current, event)
}
val media = Media(engine, mediaUri(launch.url))
try {
media.setHWDecoderEnabled(hardware, false)
launch.subtitleUrl?.let { media.addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, mediaUri(it).toString())) }
current.media = media
} finally { media.release() }
current.rate = speed
// A paused launch still prerolls to obtain duration/tracks. Mute until
// its first Playing callback has paused it, avoiding an audio burst.
if (!desiredPlaying || !foreground) current.setVolume(0)
if (desiredPlaying && foreground && !requestFocus()) desiredPlaying = false
current.play()
render()
} catch (_: Exception) { fail() }
catch (_: LinkageError) { fail() }
}
private fun handleEvent(current: MediaPlayer, event: MediaPlayer.Event) {
if (error) {
if (event.type == MediaPlayer.Event.Playing) current.pause()
return // Errors remain visible until an explicit retry creates a new engine.
}
when (event.type) {
MediaPlayer.Event.Opening -> { loading = true }
MediaPlayer.Event.Buffering -> {
loading = event.buffering < 100f
}
MediaPlayer.Event.Playing -> {
opened = true
loading = false
error = false
applyPendingSeek()
current.rate = speed
restoreTracks()
if (!desiredPlaying || (!foreground && !inPip()) || !hasFocus) current.pause()
current.setVolume(100)
snapshot()
}
MediaPlayer.Event.Paused -> { loading = false; snapshot() }
MediaPlayer.Event.TimeChanged -> {
if (pendingSeek == null) positionMs = event.timeChanged.coerceAtLeast(0)
publish()
}
// Length can temporarily collapse during buffering/decoder teardown.
// This activity owns one source, so retain its longest known length.
MediaPlayer.Event.LengthChanged -> { durationMs = maxOf(durationMs, event.lengthChanged); publish() }
MediaPlayer.Event.SeekableChanged -> applyPendingSeek()
MediaPlayer.Event.ESAdded -> restoreTracks()
MediaPlayer.Event.EndReached -> {
// A truncated/ended input is not proof that the episode finished.
// Validate the last observed checkpoint BEFORE replacing it with
// the duration; otherwise an early EOF fabricates 100% + auto-next.
val tolerance = (durationMs / 50L).coerceIn(1L, 2000L)
val atEnd = opened && pendingSeek == null && durationMs > 0 &&
positionMs > 0 && positionMs <= durationMs && durationMs - positionMs <= tolerance
if (!atEnd) {
completed = false
desiredPlaying = false
// Stopped decoders may reset their time/length. Preserve the
// last observed checkpoint for explicit retry or handoff.
fail(captureSnapshot = false)
publish()
return
}
completed = true
desiredPlaying = false
if (durationMs > 0) positionMs = durationMs
publish()
finish()
return
}
// LibVLC exposes no reliable network-vs-decoder reason here. Never
// auto-retry this event: software retry requires explicit user choice.
MediaPlayer.Event.EncounteredError -> fail()
}
render()
}
private fun applyPendingSeek() {
val value = pendingSeek ?: return
val current = player ?: return
if (current.isSeekable) {
val duration = maxOf(0L, durationMs, current.length)
positionMs = if (duration > 0) value.coerceAtMost(duration) else value
pendingSeek = null
current.setTime(positionMs)
} else if (opened && ++seekWaitTicks > 12) {
pendingSeek = null
toast("This stream does not support seeking")
}
}
private fun snapshot() {
val current = player ?: return
if (!error) {
if (pendingSeek == null && !completed) positionMs = current.time.takeIf { it >= 0 } ?: positionMs
durationMs = maxOf(durationMs, current.length)
}
publish()
}
// An overrun makes the length uncertain, not the measured checkpoint.
// Keep canonical timing intact for EOF validation and later length metadata.
private fun credibleDuration(): Long = durationMs.coerceAtLeast(0).takeIf { it >= positionMs } ?: 0L
private fun publish() {
val duration = credibleDuration()
val position = positionMs.coerceAtLeast(0)
NativePlayerRequests.results.update(requestId, position, duration, opened, completed)
setResult(RESULT_OK, Intent().putExtra(NativePlayerRequests.REQUEST_ID, requestId)
.putExtra("positionMs", position).putExtra("durationMs", duration)
.putExtra("opened", opened).putExtra("completed", completed && opened))
}
private fun render() {
if (!::overlay.isInitialized || closed) return
val current = player
overlay.render(PlayerOverlayState(
title = launch.title, positionMs = positionMs.coerceAtLeast(0), durationMs = credibleDuration(),
playing = current?.isPlaying == true, loading = loading, error = error,
seekable = current?.isSeekable == true, speed = speed, pip = inPip(),
))
if (current?.isPlaying == true && (foreground || inPip())) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
else window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
private fun togglePlay() {
val current = player ?: return
if (current.isPlaying || desiredPlaying && loading) pauseByUser()
else if (requestFocus()) {
resumeAfterFocus = false
desiredPlaying = true
current.play()
} else toast("Audio is in use by another app")
render()
}
private fun pauseByUser() {
desiredPlaying = false
resumeAfterFocus = false
player?.pause()
snapshot()
render()
}
private fun seekTo(value: Long) {
val current = player ?: return
if (!current.isSeekable || error) { toast("This stream is not seekable"); return }
positionMs = value.coerceAtLeast(0).let { if (durationMs > 0) it.coerceAtMost(durationMs) else it }
pendingSeek = null
current.setTime(positionMs)
publish()
render()
}
private fun fail(captureSnapshot: Boolean = true) {
if (captureSnapshot) snapshot()
error = true
loading = false
player?.pause()
dialog?.dismiss()
render()
}
private fun retry(software: Boolean) {
if (closed || releasing) return
snapshot()
if (software) hardware = false
desiredPlaying = true
if (player == null && vlc == null) load()
else { reloadRequested = true; releaseEngine() }
}
private fun external() {
snapshot()
pauseByUser()
try {
val ok = NativeFileIntents(this).openExternal(mapOf(
"url" to launch.url, "title" to launch.title, "positionMs" to positionMs,
"packageName" to launch.externalPackage,
"subtitleUrl" to launch.subtitleUrl.takeIf { subtitles }))
if (!ok) toast("No external video player is installed")
} catch (_: Exception) { toast("This source could not be handed to an external player") }
}
private fun restoreTracks() {
val current = player ?: return
audioTrackId?.let { id -> if (current.audioTracks?.any { it.id == id } == true) current.setAudioTrack(id) }
if (!subtitles) current.setSpuTrack(-1)
else subtitleTrackId?.let { id -> if (current.spuTracks?.any { it.id == id } == true) current.setSpuTrack(id) }
}
private fun chooseTrack(subtitle: Boolean) {
val current = player ?: return
val tracks = (if (subtitle) current.spuTracks else current.audioTracks).orEmpty().filter { it.id >= 0 }
val ids = if (subtitle) listOf(-1) + tracks.map { it.id } else tracks.map { it.id }
val names = if (subtitle) listOf("Off") + tracks.map { it.name } else tracks.map { it.name }
if (ids.isEmpty()) { toast("No audio tracks available yet"); return }
val selected = if (subtitle) { if (subtitles) current.spuTrack else -1 } else current.audioTrack
showChoices(if (subtitle) "Subtitles" else "Audio track", names, ids.indexOf(selected)) { index ->
if (player !== current) return@showChoices
val id = ids[index]
val accepted = if (subtitle) current.setSpuTrack(id) else current.setAudioTrack(id)
if (accepted) {
if (subtitle) { subtitles = id != -1; subtitleTrackId = id }
else audioTrackId = id
} else toast("This track is not available")
}
}
private fun chooseFit() {
val values = listOf("contain", "cover", "fill")
showChoices("Video fit", listOf("Fit (letterbox)", "Cover (crop)", "Stretch"), values.indexOf(fit)) {
fit = values[it]; applyFit()
}
}
private fun applyFit() {
player?.setVideoScale(when (fit) {
"cover" -> MediaPlayer.ScaleType.SURFACE_FIT_SCREEN
"fill" -> MediaPlayer.ScaleType.SURFACE_FILL
else -> MediaPlayer.ScaleType.SURFACE_BEST_FIT
})
}
private fun chooseSpeed() {
val rates = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f, 2.5f, 3f)
showChoices("Playback speed", rates.map { "${it}×" }, rates.indexOf(speed)) {
speed = rates[it]; player?.rate = speed; render()
}
}
private fun showSettings() {
showChoices("Player settings", listOf("Audio track", "Subtitles", "Video fit", "Playback speed"), -1) {
when (it) {
0 -> chooseTrack(false)
1 -> chooseTrack(true)
2 -> chooseFit()
3 -> chooseSpeed()
}
}
}
private fun showChoices(title: String, values: List<String>, selected: Int, pick: (Int) -> Unit) {
if (closed || inPip()) return
// Invalidate the old owner before dismiss(): Android delivers onDismiss
// asynchronously, potentially after the replacement sheet has opened.
val owner = menuSession.open()
dialog?.dismiss()
overlay.setMenuOpen(true)
dialog = playerChoiceSheet(this, title, values, selected,
dismissed = {
if (menuSession.close(owner)) {
dialog = null
overlay.setMenuOpen(false)
}
},
pick = { index -> if (!closed && !inPip()) pick(index) })
}
private fun requestFocus(): Boolean {
if (hasFocus) return true
hasFocus = if (Build.VERSION.SDK_INT >= 26) audio.requestAudioFocus(focusRequest!!) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
else audio.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasFocus
}
private fun abandonFocus() {
if (!::audio.isInitialized) return
if (Build.VERSION.SDK_INT >= 26) focusRequest?.let { audio.abandonAudioFocusRequest(it) }
else audio.abandonAudioFocus(focusListener)
hasFocus = false
}
private fun inPip() = Build.VERSION.SDK_INT >= 26 && isInPictureInPictureMode
private fun enterPip(): Boolean {
if (!::launch.isInitialized || !launch.pipEnabled || !opened || error || Build.VERSION.SDK_INT < 26 ||
!packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) return false
dialog?.dismiss()
return try {
enterPictureInPictureMode(PictureInPictureParams.Builder().setAspectRatio(Rational(16, 9)).build())
} catch (_: RuntimeException) { false }
}
override fun onUserLeaveHint() {
super.onUserLeaveHint()
if (desiredPlaying && player?.isPlaying == true) enterPip()
}
override fun onPictureInPictureModeChanged(inPip: Boolean, newConfig: Configuration) {
super.onPictureInPictureModeChanged(inPip, newConfig)
if (!::overlay.isInitialized) return
if (inPip) dialog?.dismiss()
render()
root.requestApplyInsets()
player?.updateVideoSurfaces()
if (!inPip && !foreground) player?.pause()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
if (::root.isInitialized) { configureWindow(); player?.updateVideoSurfaces() }
}
override fun onStart() {
super.onStart()
foreground = true
if (::overlay.isInitialized) overlay.setForeground(true)
if (!closed && ::launch.isInitialized && !error && desiredPlaying && requestFocus()) player?.play()
}
override fun onStop() {
foreground = false
if (::overlay.isInitialized) overlay.setForeground(false)
if (!inPip()) {
// Do not change desiredPlaying: returning restores the user's state.
player?.pause()
snapshot()
abandonFocus()
}
super.onStop()
}
override fun onSaveInstanceState(outState: Bundle) {
snapshot()
outState.putLong("positionMs", positionMs)
outState.putLong("durationMs", durationMs)
outState.putBoolean("opened", opened)
outState.putBoolean("playing", desiredPlaying)
outState.putFloat("speed", speed)
outState.putString("fit", fit)
outState.putBoolean("hardware", hardware)
outState.putBoolean("subtitles", subtitles)
audioTrackId?.let { outState.putInt("audioTrack", it) }
subtitleTrackId?.let { outState.putInt("subtitleTrack", it) }
super.onSaveInstanceState(outState)
}
override fun finish() {
if (!closed) {
snapshot()
publish()
closed = true
desiredPlaying = false
main.removeCallbacks(tick)
dialog?.dismiss()
if (::overlay.isInitialized) overlay.release()
abandonFocus()
releaseEngine()
}
super.finish()
}
/** View detachment must run on main; native stop/release can wait for network
* shutdown and are serialized off-main. No stale callbacks can touch a retry. */
private fun releaseEngine() {
val old = player
val engine = vlc
player = null
vlc = null
if (old == null && engine == null) return
old?.setEventListener(null)
old?.pause() // Silence immediately; native stop may wait for network I/O.
old?.detachViews()
releasing = true
teardown.execute {
try { old?.stop() } catch (_: Exception) { }
try { old?.release() } catch (_: Exception) { }
try { engine?.release() } catch (_: Exception) { }
main.post {
releasing = false
if (!closed && reloadRequested) { reloadRequested = false; load() }
}
}
}
override fun onDestroy() {
snapshot()
closed = true
main.removeCallbacksAndMessages(null)
dialog?.dismiss()
if (::overlay.isInitialized) overlay.release()
if (receiverRegistered) { unregisterReceiver(noisy); receiverRegistered = false }
abandonFocus()
releaseEngine()
teardown.shutdown()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
window.decorView.systemUiVisibility = originalSystemUi
// Orientation is activity-scoped, never mutate the Flutter host's window.
if (requestedOrientation != originalOrientation) requestedOrientation = originalOrientation
super.onDestroy()
}
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
companion object {
fun ownedRoots(context: Context): List<File> = listOfNotNull(context.filesDir, context.cacheDir,
context.getExternalFilesDir(null), context.externalCacheDir)
private fun mediaUri(value: String): Uri = if (value.startsWith('/')) Uri.fromFile(File(value)) else Uri.parse(value)
fun intent(context: Context, value: NativePlayerLaunch, id: String): Intent = Intent(context, VlcPlayerActivity::class.java)
.putExtra(NativePlayerRequests.REQUEST_ID, id)
.putExtra("url", value.url).putExtra("title", value.title).putExtra("positionMs", value.positionMs)
.putExtra("autoplay", value.autoplay).putExtra("externalPackage", value.externalPackage)
.putExtra("orientation", value.orientation).putExtra("videoFit", value.videoFit).putExtra("speed", value.speed)
.putExtra("subtitleEnabled", value.subtitleEnabled).putExtra("subtitleUrl", value.subtitleUrl)
.putExtra("hardwareAcceleration", value.hardwareAcceleration).putExtra("pipEnabled", value.pipEnabled)
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,4 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFFFF" android:pathData="M8,5v14l11,-7z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="ReelboxCastTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
<item name="colorAccent">#55DCC0</item>
<item name="android:windowActionModeOverlay">true</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path name="app_files" path="." />
<cache-path name="app_cache" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
</paths>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,183 @@
package org.reelbox.reelbox;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import kotlin.Unit;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.GraphicsMode;
import org.robolectric.annotation.LooperMode;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.shadow.api.Shadow;
import org.videolan.libvlc.MediaPlayer;
import static org.junit.Assert.*;
/** Drives the actual activity event handler and result gate, without a decoder.
* Event/checkpoint fixtures are explicit; this is not device/network playback.
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28, manifest = Config.NONE)
@LooperMode(LooperMode.Mode.PAUSED)
@GraphicsMode(GraphicsMode.Mode.LEGACY)
public class NativePlayerTimingAndroidTest {
private static Field field(String name) throws Exception {
Field field = VlcPlayerActivity.class.getDeclaredField(name);
field.setAccessible(true);
return field;
}
private static void set(VlcPlayerActivity activity, String name, Object value) throws Exception {
field(name).set(activity, value);
}
private static Object get(VlcPlayerActivity activity, String name) throws Exception {
return field(name).get(activity);
}
private static void event(VlcPlayerActivity activity, int type, long value) throws Exception {
Constructor<MediaPlayer.Event> constructor = MediaPlayer.Event.class.getDeclaredConstructor(int.class, long.class);
constructor.setAccessible(true);
MediaPlayer.Event event = constructor.newInstance(type, value);
Method handler = VlcPlayerActivity.class.getDeclaredMethod("handleEvent", MediaPlayer.class, MediaPlayer.Event.class);
handler.setAccessible(true);
// These timing/EOF branches use activity state, not a decoder instance.
// Deliberately do not start LibVLC/JNI or invent a decoded frame.
handler.invoke(activity, null, event);
}
private static VlcPlayerActivity checkpoint(long position, long duration, boolean opened) throws Exception {
VlcPlayerActivity activity = Robolectric.buildActivity(VlcPlayerActivity.class).get();
set(activity, "positionMs", position);
set(activity, "durationMs", duration);
set(activity, "opened", opened);
return activity;
}
private static void close(VlcPlayerActivity activity) throws Exception {
activity.finish();
((ExecutorService) get(activity, "teardown")).shutdownNow();
NativePlayerRequests.INSTANCE.getResults().dispose();
}
@Implements(value = MediaPlayer.class, callThroughByDefault = false)
public static class SeekDecoder {
static long lastSeek;
@Implementation protected boolean isSeekable() { return true; }
@Implementation protected long getLength() { return 120_000L; }
@Implementation protected long setTime(long value) { lastSeek = value; return 0; }
}
@Test
@Config(shadows = SeekDecoder.class, instrumentedPackages = "org.videolan.libvlc")
public void retrySeekKeepsCheckpointWhenDecoderLengthTemporarilyShrinks() throws Exception {
VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true);
MediaPlayer decoder = Shadow.newInstanceOf(MediaPlayer.class);
set(activity, "player", decoder);
set(activity, "pendingSeek", 300_000L);
SeekDecoder.lastSeek = -1;
try {
Method seek = VlcPlayerActivity.class.getDeclaredMethod("applyPendingSeek");
seek.setAccessible(true);
seek.invoke(activity);
assertEquals("Retry must not resume at a shorter, transient length", 300_000L, SeekDecoder.lastSeek);
assertEquals(300_000L, get(activity, "positionMs"));
} finally {
set(activity, "player", null); // No decoder/JNI was constructed.
close(activity);
}
}
@Test public void overrunTimeSampleCannotBeClampedIntoSuccessfulCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true);
List<Map<String, ? extends Object>> results = new ArrayList<>();
NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults();
gate.dispose();
assertTrue(gate.begin("overrun", 0L, value -> { results.add(value); return Unit.INSTANCE; }));
set(activity, "requestId", "overrun");
try {
event(activity, MediaPlayer.Event.TimeChanged, 3_600_001L);
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse("A clamped overrun must not authorize auto-next", activity.isFinishing());
assertEquals(3_600_001L, get(activity, "positionMs"));
assertEquals(false, get(activity, "completed"));
gate.finish("overrun");
assertEquals(3_600_001L, results.get(0).get("positionMs"));
assertEquals("Inconsistent duration is unknown, not a fake 100%", 0L, results.get(0).get("durationMs"));
} finally { close(activity); }
}
@Test public void transientLengthChangesKeepKnownDurationAndActualCheckpoint() throws Exception {
for (long reported : new long[] {120_000L, 0L, -1L}) {
VlcPlayerActivity activity = checkpoint(300_000L, 3_600_000L, true);
try {
event(activity, MediaPlayer.Event.LengthChanged, reported);
assertEquals("Transient length must not replace known episode duration", 3_600_000L, get(activity, "durationMs"));
assertEquals("Length metadata must not move the playback checkpoint", 300_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
}
@Test public void unknownDurationEndCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 0L, true);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
assertEquals(180_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
@Test public void pendingResumeSeekCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true);
set(activity, "pendingSeek", 3_599_500L);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
} finally { close(activity); }
}
@Test public void neverOpenedDecoderCannotClaimCompletion() throws Exception {
VlcPlayerActivity activity = checkpoint(3_600_000L, 3_600_000L, false);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse(activity.isFinishing());
assertEquals(false, get(activity, "completed"));
} finally { close(activity); }
}
@Test public void realNearEndStillCompletesNormally() throws Exception {
VlcPlayerActivity activity = checkpoint(3_599_500L, 3_600_000L, true);
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertTrue(activity.isFinishing());
assertEquals(true, get(activity, "completed"));
assertEquals(3_600_000L, get(activity, "positionMs"));
} finally { close(activity); }
}
@Test public void prematureEndDoesNotFinishOrManufacture100Percent() throws Exception {
VlcPlayerActivity activity = checkpoint(180_000L, 3_600_000L, true);
List<Map<String, ? extends Object>> results = new ArrayList<>();
NativePlayerResultGate gate = NativePlayerRequests.INSTANCE.getResults();
gate.dispose();
assertTrue(gate.begin("early-eof", 0L, value -> { results.add(value); return Unit.INSTANCE; }));
set(activity, "requestId", "early-eof");
try {
event(activity, MediaPlayer.Event.EndReached, 0);
assertFalse("An early decoder EOF must not finish the activity and auto-next", activity.isFinishing());
assertEquals("Keep the actual three-minute checkpoint", 180_000L, get(activity, "positionMs"));
assertEquals(false, get(activity, "completed"));
assertEquals("Show recovery instead of silently skipping the episode", true, get(activity, "error"));
gate.finish("early-eof");
assertEquals(1, results.size());
assertEquals(180_000L, results.get(0).get("positionMs"));
assertEquals(3_600_000L, results.get(0).get("durationMs"));
assertEquals(false, results.get(0).get("completed"));
} finally { close(activity); }
}
}
@@ -0,0 +1,785 @@
package org.reelbox.reelbox;
import static org.junit.Assert.*;
import android.app.Activity;
import android.graphics.Rect;
import android.os.Looper;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewGroup;
import android.widget.SeekBar;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.android.controller.ActivityController;
import org.robolectric.annotation.Config;
import org.robolectric.annotation.GraphicsMode;
import org.robolectric.annotation.LooperMode;
/**
* Host-side tests of the actual native view, not a policy model or source-string checks.
* PAUSED gives deterministic main Handler/gesture timing. LEGACY tests real Android view
* geometry and touch dispatch, NOT device pixels, native Skia, video decoding or PiP OS UI.
*/
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28, manifest = Config.NONE, qualifiers = "w360dp-h780dp-mdpi")
@LooperMode(LooperMode.Mode.PAUSED)
@GraphicsMode(GraphicsMode.Mode.LEGACY)
public class PlayerOverlayAndroidTest {
private ActivityController<Activity> controller;
private PlayerOverlayView overlay;
private RecordingCallbacks callbacks;
private int width = 360;
private int height = 780;
private boolean released;
private static final class RecordingCallbacks implements PlayerOverlayView.Callbacks {
int closes, toggles, settings, external, pip;
final List<Long> relative = new ArrayList<>();
final List<Long> absolute = new ArrayList<>();
final List<Long> absoluteAt = new ArrayList<>();
final List<Boolean> retries = new ArrayList<>();
Runnable closeHook, relativeHook;
@Override public void onClose() { closes++; if (closeHook != null) closeHook.run(); }
@Override public void onTogglePlayback() { toggles++; }
@Override public void onSeekRelative(long deltaMs) {
relative.add(deltaMs);
if (relativeHook != null) relativeHook.run();
}
@Override public void onSeekTo(long positionMs) {
absoluteAt.add(SystemClock.uptimeMillis());
absolute.add(positionMs);
}
@Override public void onSettings() { settings++; }
@Override public void onExternal() { external++; }
@Override public void onRetry(boolean software) { retries.add(software); }
@Override public void onPictureInPicture() { pip++; }
int actionCount() {
return closes + toggles + settings + external + pip
+ relative.size() + absolute.size() + retries.size();
}
}
@Before public void createRealAttachedOverlay() {
controller = Robolectric.buildActivity(Activity.class);
controller.get().setTheme(android.R.style.Theme_Material_NoActionBar);
controller.setup().visible();
callbacks = new RecordingCallbacks();
overlay = new PlayerOverlayView(controller.get(), callbacks);
controller.get().setContentView(overlay);
overlay.setForeground(true);
overlay.render(state(true, false, false));
layout(360, 780);
tagged("player-video-gestures");
}
@After public void destroyRealAttachedOverlay() {
if (overlay != null && !released) overlay.release();
if (controller != null) controller.pause().stop().destroy();
}
private PlayerOverlayState state(boolean playing, boolean error, boolean pip) {
return stateAt(30_000L, playing, error, pip);
}
private PlayerOverlayState stateAt(long position, boolean playing, boolean error, boolean pip) {
return new PlayerOverlayState("A long title — عنوان فارسی برای آزمایش رابط ویدیو",
position, 120_000L, playing, false, error, true, 1.0f, pip);
}
private void layout(int w, int h) {
width = w; height = h;
overlay.measure(View.MeasureSpec.makeMeasureSpec(w, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(h, View.MeasureSpec.EXACTLY));
overlay.layout(0, 0, w, h);
}
private View tagged(String tag) {
View result = overlay.findViewWithTag(tag);
assertNotNull("Required real view tag missing: " + tag, result);
return result;
}
private boolean visible(View view) {
for (View node = view; node != null; ) {
if (node.getVisibility() != View.VISIBLE || node.getAlpha() <= 0f) return false;
if (node == overlay) return true;
node = node.getParent() instanceof View ? (View) node.getParent() : null;
}
return false;
}
private void assertChrome(boolean expected) {
assertEquals("Chrome visibility", expected, visible(tagged("player-controls")));
}
private PlayerControlsState controls() {
try {
java.lang.reflect.Field field = overlay.getClass().getDeclaredField("controls");
field.setAccessible(true);
return (PlayerControlsState) field.get(overlay);
} catch (ReflectiveOperationException error) { throw new AssertionError(error); }
}
private void idle(long milliseconds) {
Shadows.shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(milliseconds));
}
private void idleUntil(long uptime) {
long remaining = uptime - SystemClock.uptimeMillis();
if (remaining > 0) idle(remaining);
}
private Rect bounds(View child) {
Rect rect = new Rect(0, 0, child.getWidth(), child.getHeight());
overlay.offsetDescendantRectToMyCoords(child, rect);
return rect;
}
private void dispatch(int action, float x, float y, long downTime) {
MotionEvent event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0);
try { overlay.dispatchTouchEvent(event); }
finally { event.recycle(); }
}
private void rawTap(float x, float y) {
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
idle(16);
dispatch(MotionEvent.ACTION_UP, x, y, down);
// Android View posts PerformClick at the current uptime; drain it without
// advancing through GestureDetector's delayed single-tap confirmation.
Shadows.shadowOf(Looper.getMainLooper()).idle();
}
private void tapVideo() {
// Away from header, transport buttons, unlock button and bottom scrub bar.
rawTap(width * 0.17f, height * 0.27f);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
}
private void doubleTapVideo(float fraction) {
rawTap(width * fraction, height * 0.32f);
idle(70);
rawTap(width * fraction, height * 0.32f);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
}
private void tapControl(String tag) {
layout(width, height);
View target = tagged(tag);
assertTrue("Control is not visible: " + tag, visible(target));
assertTrue("Control disabled: " + tag, target.isEnabled());
Rect rect = bounds(target);
assertTrue("Control has no touch area: " + tag + " " + rect, rect.width() > 0 && rect.height() > 0);
rawTap(rect.exactCenterX(), rect.exactCenterY());
}
@Test public void videoSingleTapHidesAndShowsChrome() {
assertChrome(true);
tapVideo();
assertChrome(false);
tapVideo();
assertChrome(true);
}
@Test public void playingAutoHidesAtThreeSeconds() {
idle(2999);
assertChrome(true);
idle(2);
assertChrome(false);
}
@Test public void progressRendersDoNotExtendIdleDeadline() {
idle(1000);
overlay.render(stateAt(31_000L, true, false, false));
idle(1000);
overlay.render(stateAt(32_000L, true, false, false));
idle(1001);
assertChrome(false);
}
@Test public void progressRenderNeverReshowsHiddenChrome() {
tapVideo();
assertChrome(false);
overlay.render(stateAt(31_000L, true, false, false));
layout(width, height);
assertChrome(false);
idle(4000);
assertChrome(false);
}
@Test public void scrubHoldsTimerAndReleaseResumesIt() {
View seek = tagged("player-seek");
assertTrue("Real native SeekBar required", seek instanceof SeekBar);
idle(2000);
// Window traversal can change attached-view bounds. Resolve the live
// touch target AFTER idling, not from a stale pre-traversal rectangle.
Rect rect = bounds(seek);
float y = rect.exactCenterY();
float x = rect.left + rect.width() * 0.35f;
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * 0.10f, y, down);
assertTrue("Real SeekBar must receive and track the touch", seek.isPressed());
assertTrue("Finger tracking must only preview until UP", callbacks.absolute.isEmpty());
idle(4500);
assertChrome(true);
long stoppedAt = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_UP, x + rect.width() * 0.10f, y, down);
assertEquals("Finger UP commits exactly once", 1, callbacks.absolute.size());
long position = callbacks.absolute.get(callbacks.absolute.size() - 1);
assertTrue("Seek target must be within duration: " + position, position >= 0 && position <= 120_000L);
Long deadline = controls().getDeadlineMs();
assertEquals("Production idle interval stays exactly 3000ms", Long.valueOf(callbacks.absoluteAt.get(0) + 3000), deadline);
// Android's pressed/ripple traversal advances Robolectric uptime during UP
// (observed +15ms). Relative idleFor(2999) AFTER UP crossed the deadline.
// Assert against the production monotonic deadline, without widening it.
System.out.println("SCRUB_CLOCK stoppedAt=" + stoppedAt + " afterUp=" + SystemClock.uptimeMillis()
+ " deadline=" + deadline);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
assertEquals(1, callbacks.absolute.size());
}
@Test public void errorCancelsActiveScrubWithoutCommittingAndRetryRecovers() {
assertCancelledScrubRecovers("error");
}
@Test public void seekabilityLossCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("unseekable");
}
@Test public void pipCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("pip");
}
@Test public void lockCancelsActiveScrubWithoutCommitting() {
assertCancelledScrubRecovers("lock");
}
@Test public void seekabilityRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("unseekable", false);
}
@Test public void errorRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("error", false);
}
@Test public void pipRecoveryBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("pip", false);
}
@Test public void unlockBeforeFingerUpCannotCommitCancelledScrub() {
assertRecoveryBeforeFingerUp("lock", false);
}
@Test public void cancelledTouchSuppressionDoesNotBlockAccessibilitySeeking() {
assertRecoveryBeforeFingerUp("unseekable", true);
}
private void assertRecoveryBeforeFingerUp(String interruption, boolean accessibility) {
idle(1000);
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
float x = rect.left + rect.width() * .35f;
float y = rect.exactCenterY();
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .15f, y, down);
assertTrue("Fixture must start actual native tracking", seek.isPressed());
assertTrue(callbacks.absolute.isEmpty());
if (interruption.equals("lock")) tagged("player-lock").performClick();
else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L,
true, false, interruption.equals("error"), !interruption.equals("unseekable"),
1f, interruption.equals("pip")));
assertFalse(seek.isPressed());
if (interruption.equals("lock")) tagged("player-unlock").performClick();
// Recover WHILE the original finger is still down: its parent touch
// target must not be allowed to restart native tracking on MOVE or UP.
overlay.render(stateAt(75_000L, true, false, false));
layout(width, height);
assertTrue(seek.isEnabled());
if (accessibility) {
assertTrue(seek.performAccessibilityAction(
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null));
assertEquals("Non-touch seeking remains immediate", 1, callbacks.absolute.size());
callbacks.absolute.clear();
}
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .25f, y, down);
assertFalse("Cancelled finger cannot restart native tracking after recovery", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, x + rect.width() * .25f, y, down);
assertTrue("Cancelled finger cannot commit after recovery", callbacks.absolute.isEmpty());
assertFalse(seek.isPressed());
rect = bounds(seek);
long freshDown = SystemClock.uptimeMillis();
float freshX = rect.left + rect.width() * .3f;
dispatch(MotionEvent.ACTION_DOWN, freshX, rect.exactCenterY(), freshDown);
dispatch(MotionEvent.ACTION_MOVE, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown);
assertTrue("A fresh finger stream must still start tracking", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, freshX + rect.width() * .2f, rect.exactCenterY(), freshDown);
assertEquals("A fresh scrub commits exactly once", 1, callbacks.absolute.size());
}
private void assertCancelledScrubRecovers(String interruption) {
idle(2000);
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
float x = rect.left + rect.width() * .35f;
float y = rect.exactCenterY();
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, x, y, down);
dispatch(MotionEvent.ACTION_MOVE, x + rect.width() * .2f, y, down);
assertTrue("Test requires real active SeekBar tracking", seek.isPressed());
assertTrue("Finger drag is preview only until release", callbacks.absolute.isEmpty());
if (interruption.equals("lock")) tagged("player-lock").performClick();
else overlay.render(new PlayerOverlayState("Interrupted", 45_000L, 120_000L,
true, false, interruption.equals("error"), !interruption.equals("unseekable"),
1f, interruption.equals("pip")));
assertFalse("Interruption must cancel pressed/tracking before disabling SeekBar", seek.isPressed());
dispatch(MotionEvent.ACTION_UP, x + rect.width() * .2f, y, down);
assertTrue("Cancelled scrub must never reach decoder callback", callbacks.absolute.isEmpty());
if (interruption.equals("error")) {
tagged("player-retry").performClick();
assertEquals(List.of(false), callbacks.retries);
}
if (interruption.equals("lock")) tagged("player-unlock").performClick();
overlay.render(stateAt(75_000L, true, false, false));
assertEquals("Recovery must resume real progress snapshots", 6250, seek.getProgress());
assertEquals("1:15", ((android.widget.TextView) tagged("player-clock")).getText().toString());
assertTrue(seek.isEnabled());
Long deadline = controls().getDeadlineMs();
assertNotNull("Cancelled seek cannot strand visibility hold", deadline);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
}
@Test public void nativeTouchCancelDoesNotCommitScrub() {
SeekBar seek = (SeekBar) tagged("player-seek");
Rect rect = bounds(seek);
long down = SystemClock.uptimeMillis();
dispatch(MotionEvent.ACTION_DOWN, rect.exactCenterX(), rect.exactCenterY(), down);
dispatch(MotionEvent.ACTION_MOVE, rect.right - 30, rect.exactCenterY(), down);
assertTrue(seek.isPressed());
dispatch(MotionEvent.ACTION_CANCEL, rect.right - 30, rect.exactCenterY(), down);
assertFalse(seek.isPressed());
assertTrue("ACTION_CANCEL is not a committed seek", callbacks.absolute.isEmpty());
assertNotNull(controls().getDeadlineMs());
}
@Test public void keyboardSeekCommitsImmediatelyAndRenewsVisibleDeadline() {
assertNonTouchSeekCommits(false);
}
@Test public void accessibilitySeekCommitsImmediatelyAndRenewsVisibleDeadline() {
assertNonTouchSeekCommits(true);
}
private void assertNonTouchSeekCommits(boolean accessibility) {
SeekBar seek = (SeekBar) tagged("player-seek");
long oldDeadline = controls().getDeadlineMs();
idle(2500);
int oldProgress = seek.getProgress();
long interactedAt = SystemClock.uptimeMillis();
if (accessibility) assertTrue(seek.performAccessibilityAction(
android.view.accessibility.AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, null));
else {
seek.requestFocus();
assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT)));
seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_UP, android.view.KeyEvent.KEYCODE_DPAD_RIGHT));
}
assertTrue("Real user action must move SeekBar", seek.getProgress() > oldProgress);
assertEquals("Non-touch change has no stop-tracking event: commit it immediately once",
List.of(120_000L * seek.getProgress() / 10000), callbacks.absolute);
Long deadline = controls().getDeadlineMs();
assertNotNull(deadline);
assertTrue("Non-touch seek must renew interaction deadline", deadline >= interactedAt + 3000);
idleUntil(oldDeadline + 1);
assertChrome(true);
idleUntil(deadline - 1);
assertChrome(true);
idleUntil(deadline);
assertChrome(false);
assertEquals("No deferred duplicate seek", 1, callbacks.absolute.size());
}
@Test public void maximumNativeDurationSeekCannotOverflow() {
long duration = Long.MAX_VALUE / 1000L; // Accepted native millisecond range.
overlay.render(new PlayerOverlayState("Long source", duration / 4, duration,
true, false, false, true, 1f, false));
SeekBar seek = (SeekBar) tagged("player-seek");
seek.setKeyProgressIncrement(10000);
seek.requestFocus();
assertTrue(seek.dispatchKeyEvent(new android.view.KeyEvent(
android.view.KeyEvent.ACTION_DOWN, android.view.KeyEvent.KEYCODE_DPAD_RIGHT)));
assertEquals(10000, seek.getProgress());
assertEquals("End-of-timeline target must equal duration without intermediate overflow",
List.of(duration), callbacks.absolute);
}
@Test public void openMenuHoldsTimerAndDismissResumesIt() {
idle(2000);
tapControl("player-settings");
assertEquals("Settings dispatch is immediate", 1, callbacks.settings);
overlay.setMenuOpen(true);
idle(4500);
assertChrome(true);
overlay.render(stateAt(34_000L, true, false, false));
assertChrome(true);
overlay.setMenuOpen(false);
idle(2999);
assertChrome(true);
idle(2);
assertChrome(false);
}
@Test public void delayedOldDialogDismissCannotReleaseNestedSheetHold() {
PlayerMenuSession sessions = new PlayerMenuSession();
android.app.Dialog[] sheets = new android.app.Dialog[2];
int[] dismissals = {0};
Object oldOwner = sessions.open();
overlay.setMenuOpen(true);
sheets[0] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Settings",
List.of("Playback speed"), -1,
() -> {
dismissals[0]++;
if (sessions.close(oldOwner)) overlay.setMenuOpen(false);
return kotlin.Unit.INSTANCE;
},
index -> {
// Replacement invalidates the old owner BEFORE dismissing it.
Object newOwner = sessions.open();
sheets[0].dismiss();
overlay.setMenuOpen(true);
sheets[1] = PlayerOverlayViewKt.playerChoiceSheet(controller.get(), "Playback speed",
List.of("1×", "2×"), 0,
() -> {
if (sessions.close(newOwner)) overlay.setMenuOpen(false);
return kotlin.Unit.INSTANCE;
}, value -> kotlin.Unit.INSTANCE);
return kotlin.Unit.INSTANCE;
});
View row = findDescription(sheets[0].getWindow().getDecorView(), "Playback speed");
assertNotNull(row);
row.performClick();
assertNotNull("Actual choice must open a nested sheet", sheets[1]);
assertEquals("Old onDismiss must still be queued", 0, dismissals[0]);
idle(4000);
assertEquals(1, dismissals[0]);
assertTrue(sheets[1].isShowing());
assertChrome(true);
assertNull("Nested sheet must retain the auto-hide hold", controls().getDeadlineMs());
long dismissedAt = SystemClock.uptimeMillis();
sheets[1].dismiss();
idleUntil(dismissedAt + 2999);
assertChrome(true);
idleUntil(dismissedAt + 3001);
assertChrome(false);
}
private View findDescription(View root, String description) {
if (description.equals(String.valueOf(root.getContentDescription()))) return root;
if (root instanceof ViewGroup) {
ViewGroup group = (ViewGroup) root;
for (int i = 0; i < group.getChildCount(); i++) {
View result = findDescription(group.getChildAt(i), description);
if (result != null) return result;
}
}
return null;
}
@Test public void pausedSingleTapStillHidesChrome() {
overlay.render(state(false, false, false));
idle(5000);
assertChrome(true);
tapVideo();
assertChrome(false);
idle(5000);
assertChrome(false);
}
@Test public void playButtonDispatchesImmediatelyWithoutGestureDelay() {
tapControl("player-play");
assertEquals(1, callbacks.toggles);
assertChrome(true);
idle(ViewConfiguration.getDoubleTapTimeout() + 40L);
assertEquals("Button must not also bubble into video gesture", 1, callbacks.toggles);
assertChrome(true);
}
@Test public void leftDoubleTapSeeksBackWithoutSingleTapChromeToggle() {
doubleTapVideo(0.18f);
assertEquals(List.of(-10_000L), callbacks.relative);
assertEquals(0, callbacks.toggles);
assertChrome(true);
}
@Test public void rightDoubleTapSeeksForwardWithoutSingleTapChromeToggle() {
doubleTapVideo(0.82f);
assertEquals(List.of(10_000L), callbacks.relative);
assertEquals(0, callbacks.toggles);
assertChrome(true);
}
@Test public void lockedSuppressesChromeGesturesAndSeekingUntilUnlock() {
Rect play = bounds(tagged("player-play"));
Rect seek = bounds(tagged("player-seek"));
tapControl("player-lock");
assertTrue("Unlock affordance must remain visible", visible(tagged("player-unlock")));
assertChrome(false);
List<View> lockedTargets = new ArrayList<>();
collectClickable(overlay, lockedTargets);
lockedTargets.remove(tagged("player-video-gestures")); // Consuming surface, tested below.
assertEquals("Unlock must be the only active visible control", List.of(tagged("player-unlock")), lockedTargets);
int before = callbacks.actionCount();
tapVideo();
doubleTapVideo(0.18f);
doubleTapVideo(0.82f);
rawTap(play.exactCenterX(), play.exactCenterY());
rawTap(seek.left + seek.width() * 0.8f, seek.exactCenterY());
assertEquals("Locked controls or gestures dispatched a callback", before, callbacks.actionCount());
assertChrome(false);
assertTrue(visible(tagged("player-unlock")));
tapControl("player-unlock");
assertChrome(true);
assertFalse(visible(tagged("player-unlock")));
tapControl("player-play");
assertEquals(1, callbacks.toggles);
}
@Test public void pictureInPictureHidesAllOverlayUiAndSuppressesTaps() {
overlay.render(state(true, false, true));
layout(width, height);
assertChrome(false);
for (String tag : List.of("player-play", "player-back", "player-external", "player-settings",
"player-lock", "player-unlock", "player-seek", "player-loading", "player-error")) {
assertFalse("PiP leaked visible overlay: " + tag, visible(tagged(tag)));
}
List<View> pipTargets = new ArrayList<>();
collectClickable(overlay, pipTargets);
assertTrue("PiP must expose no active overlay targets", pipTargets.isEmpty());
int before = callbacks.actionCount();
tapVideo();
doubleTapVideo(0.18f);
doubleTapVideo(0.82f);
idle(4000);
assertEquals(before, callbacks.actionCount());
assertChrome(false);
}
@Test public void errorClearsLockAndPresentsReachableRecovery() {
tapControl("player-lock");
assertTrue(visible(tagged("player-unlock")));
overlay.render(state(false, true, false));
layout(width, height);
assertFalse("Error must clear lock", visible(tagged("player-unlock")));
View error = tagged("player-error");
assertTrue("Recovery panel must be visible", visible(error));
List<View> recoveryButtons = new ArrayList<>();
collectClickable(error, recoveryButtons);
assertFalse("Recovery panel must offer an actionable control", recoveryButtons.isEmpty());
// Click real recovery controls; onRetry(false/true) must be reachable, not just text.
for (View button : recoveryButtons) {
Rect rect = bounds(button);
rawTap(rect.exactCenterX(), rect.exactCenterY());
}
assertTrue("Retry must be reachable from error recovery UI", callbacks.retries.contains(false));
idle(5000);
assertTrue("Error recovery cannot auto-hide", visible(error));
}
private void collectClickable(View view, List<View> result) {
if (!visible(view)) return;
if (view.isClickable() && view.isEnabled()) result.add(view);
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) collectClickable(group.getChildAt(i), result);
}
}
@Test public void releaseCancelsPendingAutoHideAndGestureCallbacks() throws Exception {
// Check real Android Handler queue removal, not merely a released-state guard.
rawTap(width * 0.17f, height * 0.27f);
assertChrome(true);
List<android.os.Handler> handlers = new ArrayList<>();
boolean hadPendingCallbacks = false;
for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) {
if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue;
field.setAccessible(true);
android.os.Handler handler = (android.os.Handler) field.get(overlay);
assertSame("Overlay Handler must use Android's main looper", Looper.getMainLooper(), handler.getLooper());
handlers.add(handler);
hadPendingCallbacks |= handler.hasMessages(0); // Runnable posts use what=0.
}
assertFalse("Expected real overlay Handler", handlers.isEmpty());
assertTrue("Test must start with actual timer callbacks queued", hadPendingCallbacks);
int before = callbacks.actionCount();
overlay.release();
released = true;
for (android.os.Handler handler : handlers) {
assertFalse("release left callbacks on the overlay Handler", handler.hasMessages(0));
}
boolean chromeAfterRelease = visible(tagged("player-controls"));
idle(10_000);
assertEquals(before, callbacks.actionCount());
assertEquals("No delayed gesture may change released UI", chromeAfterRelease, visible(tagged("player-controls")));
}
@Test public void closeCallbackReleaseFreezesLastFrameAndLeavesNoCallbacks() throws Exception {
String[] frozen = new String[1];
callbacks.closeHook = () -> {
frozen[0] = uiSnapshot(overlay);
overlay.release();
released = true;
};
tagged("player-back").performClick();
assertEquals(1, callbacks.closes);
assertEquals("Click wrapper must not repaint after callback released overlay", frozen[0], uiSnapshot(overlay));
assertReleasedHasNoCallbacks();
idle(10_000);
assertEquals(frozen[0], uiSnapshot(overlay));
assertEquals(1, callbacks.actionCount());
}
@Test public void gestureCallbackReleaseCannotPaintOrScheduleSeekFeedback() throws Exception {
String[] frozen = new String[1];
callbacks.relativeHook = () -> {
frozen[0] = uiSnapshot(overlay);
overlay.release();
released = true;
};
doubleTapVideo(.82f);
assertNotNull(frozen[0]);
assertEquals("Double tap must stop after callback releases overlay", frozen[0], uiSnapshot(overlay));
assertReleasedHasNoCallbacks();
idle(10_000);
assertEquals(frozen[0], uiSnapshot(overlay));
assertEquals(1, callbacks.actionCount());
}
private void assertReleasedHasNoCallbacks() throws Exception {
for (java.lang.reflect.Field field : overlay.getClass().getDeclaredFields()) {
if (!android.os.Handler.class.isAssignableFrom(field.getType())) continue;
field.setAccessible(true);
android.os.Handler handler = (android.os.Handler) field.get(overlay);
assertFalse("Released overlay scheduled a later callback", handler.hasMessages(0));
}
}
private String uiSnapshot(View view) {
StringBuilder result = new StringBuilder();
result.append(view.getClass().getSimpleName()).append(':').append(view.getTag())
.append(':').append(view.getVisibility()).append(':').append(view.isEnabled())
.append(':').append(view.getAlpha()).append(':').append(view.getContentDescription());
if (view instanceof android.widget.TextView) result.append(':').append(((android.widget.TextView) view).getText());
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int i = 0; i < group.getChildCount(); i++) result.append('[').append(uiSnapshot(group.getChildAt(i))).append(']');
}
return result.toString();
}
@Test public void backgroundStopsTimerAndForegroundResumesIt() {
idle(2000);
overlay.setForeground(false);
idle(5000);
assertChrome(true);
long resumedAt = SystemClock.uptimeMillis();
overlay.setForeground(true);
// Frame traversal inside setForeground may itself advance shadow time.
// Anchor to the interaction, with a small vsync allowance at the boundary.
idleUntil(resumedAt + 2950);
assertChrome(true);
idleUntil(resumedAt + 3050);
assertChrome(false);
}
@Test public void closeAndExternalButtonsDispatchThroughRealTouch() {
tapControl("player-external");
assertEquals(1, callbacks.external);
tapControl("player-back");
assertEquals(1, callbacks.closes);
assertEquals(0, callbacks.toggles);
}
@Test public void safeInsetsProtectControlsWithoutLeavingScrimGap() {
for (int[] size : new int[][] {{360, 780}, {780, 360}}) {
overlay.setSafeInsets(28, 32, 16, 24);
layout(size[0], size[1]);
View header = (View) tagged("player-back").getParent();
assertNotNull("Header owns the actual top shadow", header.getBackground());
assertEquals("Top scrim must start at the physical top, not below the safe area", 0, bounds(header).top);
assertEquals(0, bounds(header).left);
assertEquals(size[0], bounds(header).right);
assertEquals(32, bounds(tagged("player-back")).top);
assertEquals(28, bounds(tagged("player-back")).left);
assertEquals(size[0] - 16, bounds(tagged("player-external")).right);
assertEquals(size[1] - 24, bounds(tagged("player-settings")).bottom);
assertEquals(new Rect(0, 0, size[0], size[1]), bounds(tagged("player-video-gestures")));
overlay.setSafeInsets(0, 0, 0, 0);
layout(size[0], size[1]);
assertEquals("Immersive/PiP inset removal must move controls back to the top", 0,
bounds(tagged("player-back")).top);
}
}
@Test public void portraitControlsStayInsideViewportAndDoNotOverlap() {
assertGeometry(360, 780);
}
@Test public void landscapeControlsStayInsideViewportAndDoNotOverlap() {
assertGeometry(780, 360);
}
private void assertGeometry(int w, int h) {
layout(w, h);
Rect viewport = new Rect(0, 0, w, h);
List<View> controls = new ArrayList<>();
for (String tag : List.of("player-play", "player-back", "player-external", "player-settings",
"player-lock", "player-seek", "player-clock", "player-remaining")) {
View view = tagged(tag);
assertTrue("Required control not visible in " + w + "x" + h + ": " + tag, visible(view));
Rect rect = bounds(view);
System.out.println("HOST_LAYOUT " + w + "x" + h + " " + tag + "=" + rect);
assertTrue("Zero-sized control " + tag + ": " + rect, rect.width() > 0 && rect.height() > 0);
assertTrue("Offscreen control " + tag + ": " + rect + " viewport=" + viewport, viewport.contains(rect));
controls.add(view);
}
// Also cover real extra controls (e.g. PiP and rewind), without prescribing
// extra tags beyond the shared interface or overlooking sibling additions.
List<View> extraTargets = new ArrayList<>();
collectClickable(tagged("player-controls"), extraTargets);
for (View view : extraTargets) {
if (controls.contains(view)) continue;
Rect rect = bounds(view);
System.out.println("HOST_LAYOUT " + w + "x" + h + " " + view.getTag() + "=" + rect);
assertTrue("Zero-sized touch target: " + view.getTag(), rect.width() > 0 && rect.height() > 0);
assertTrue("Offscreen touch target " + view.getTag() + ": " + rect, viewport.contains(rect));
controls.add(view);
}
for (int i = 0; i < controls.size(); i++) {
for (int j = i + 1; j < controls.size(); j++) {
View a = controls.get(i), b = controls.get(j);
assertFalse("Overlapping controls " + a.getTag() + "=" + bounds(a)
+ " and " + b.getTag() + "=" + bounds(b), Rect.intersects(bounds(a), bounds(b)));
}
}
}
}
@@ -0,0 +1,79 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.NativePlayerLaunch
/** JVM-only tests, run without an Android runtime or network. */
fun main() {
val source = "https://media.example.org/a%2Fb.m3u8?sig=abc%2Bz&x=1"
val launch = NativePlayerLaunch.from(mapOf("url" to source, "positionMs" to 12345L,
"autoplay" to false, "title" to "عنوان"), emptyList())
check(launch.url == source)
check(launch.positionMs == 12345L)
check(!launch.autoplay)
check(launch.title == "عنوان")
val root = java.nio.file.Files.createTempDirectory("native-player-policy").toFile()
try {
val local = java.io.File(root, "episode.mkv").apply { writeText("fixture") }
check(NativePlayerLaunch.from(mapOf("url" to local.path), listOf(root)).url == local.canonicalPath)
check(NativePlayerLaunch.from(mapOf("url" to local.toURI().toString()), listOf(root)).url == local.canonicalPath)
val loopback = "http://127.0.0.1:49321/media/version/a.m3u8?token=opaque"
check(!org.reelbox.reelbox.NativeUrlPolicy.allowsRemote(loopback))
check(NativePlayerLaunch.from(mapOf("url" to loopback), emptyList()).url == loopback)
val invalid = listOf("content://foreign/provider", "/etc/passwd", "file:///etc/passwd",
"https://u:p@example.org/media.mp4", "javascript:alert(1)",
java.io.File(root, "../outside.mp4").path, "file://evil.example${local.path}",
"https://t.me/channel", java.io.File(root, "video.apk").apply { writeText("fixture") }.path)
for (url in invalid) {
check(runCatching { NativePlayerLaunch.from(mapOf("url" to url), listOf(root)) }.isFailure)
}
val outside = java.io.File(root.parentFile, "outside-native-test.mkv").apply { writeText("fixture") }
try {
val link = java.io.File(root, "link.mkv")
java.nio.file.Files.createSymbolicLink(link.toPath(), outside.toPath())
check(runCatching { NativePlayerLaunch.from(mapOf("url" to link.path), listOf(root)) }.isFailure)
} finally { outside.delete() }
val options = NativePlayerLaunch.from(mapOf("url" to source, "speed" to 1.25,
"orientation" to "landscape", "videoFit" to "cover", "externalPackage" to "vlc",
"subtitleEnabled" to false, "subtitleUrl" to local.path, "pipEnabled" to false,
"hardwareAcceleration" to false, "positionMs" to -3), listOf(root))
check(options.speed == 1.25f && options.orientation == "landscape" && options.videoFit == "cover")
check(options.externalPackage == "vlc" && !options.subtitleEnabled && options.subtitleUrl == local.canonicalPath)
check(!options.pipEnabled && !options.hardwareAcceleration && options.positionMs == 0L)
check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "speed" to Double.NaN), emptyList()) }.isFailure)
check(runCatching { NativePlayerLaunch.from(mapOf("url" to source, "externalPackage" to "a;b"), emptyList()) }.isFailure)
} finally { root.deleteRecursively() }
val gate = org.reelbox.reelbox.NativePlayerResultGate()
val delivered = mutableListOf<Map<String, Any>>()
check(gate.begin("title:007/source:a%2Fb", 12000L) { delivered.add(it) })
check(!gate.begin("other", 0L) { error("Busy request replaced") })
check(!gate.update("wrong", 1L, 2L, true, true))
check(!gate.finish("wrong"))
check(delivered.isEmpty())
check(gate.update("title:007/source:a%2Fb", 32000L, 90000L, true, false))
check(gate.finish("title:007/source:a%2Fb"))
check(!gate.finish("title:007/source:a%2Fb"))
check(delivered.single() == mapOf("positionMs" to 32000L, "durationMs" to 90000L,
"opened" to true, "completed" to false))
check(gate.begin("retry", -7L) { delivered.add(it) })
gate.update("retry", Long.MAX_VALUE, 123L, true, true)
gate.finish("retry")
check(delivered.last()["positionMs"] == 123L && delivered.last()["completed"] == true)
check(gate.begin("destroy", 42L) { delivered.add(it) })
gate.dispose()
gate.dispose()
check(delivered.size == 3 && delivered.last()["positionMs"] == 42L && delivered.last()["opened"] == false)
check(gate.begin("reentrant", 0L) { check(gate.begin("next", 0L) {}) })
check(gate.finish("reentrant"))
check(gate.activeId == "next")
gate.dispose()
check(gate.begin("detached-engine", 9L) { throw IllegalStateException("Flutter engine detached") })
check(runCatching { gate.finish("detached-engine") }.getOrDefault(false))
check(gate.activeId == null)
check(!gate.finish("detached-engine"))
check(gate.begin("oversized-metadata", Long.MAX_VALUE) { delivered.add(it) })
gate.update("oversized-metadata", Long.MAX_VALUE, Long.MAX_VALUE, true, false)
gate.finish("oversized-metadata")
check(delivered.last()["durationMs"] == Long.MAX_VALUE / 1000L)
check(delivered.last()["positionMs"] == Long.MAX_VALUE / 1000L)
println("Native player launch/policy and exactly-once result regression cases passed")
}
@@ -0,0 +1,38 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.NativeUrlPolicy
/** JVM executable: no Android runtime, credentials, network, or Gradle. */
fun main() {
var assertions = 0
fun expect(condition: Boolean, message: () -> Any = { "Assertion failed" }) {
assertions++
check(condition, message)
}
expect(NativeUrlPolicy.allowsRemote("https://media.example.org/movie.m3u8?token=opaque"))
expect(NativeUrlPolicy.allowsRemote("http://192.168.1.4/video.mp4"))
for (url in listOf(
"javascript:alert(1)", "intent://play", "file:///sdcard/video.mp4", "https://t.me/channel",
"https://example.org/app.APK?download=1", "https://example.org/%75pdate/latest",
"https://example.org/support/contact", "https://example.org/telegram", "https://melodybox.example/store",
"https://norton.com/promo", "https://example.org/news", "https://example.org/shop",
"https://user:pass@example.org/video.mp4", "https://example.org/app%252eapk",
"https://example.org/مَلُودِي%20باكس", "https://example.org/نُورْتُون",
"https://example.org/setup.exe", "https://example.org/app.dmg", "https://example.org/install.ipa",
"https://example.org/video%5cplayer.mp4", "https://example.org/%2575pdate/latest",
"https://example.org/%D8%AA%D9%84%DA%AF%D8%B1%D8%A7%D9%85",
"https://example.org/?next=https%3A%2F%2Ft.me%2Fchannel", "https://example.org/?next=melody&#98;ox"
)) expect(!NativeUrlPolicy.allowsRemote(url)) { "Unsafe URL accepted: $url" }
expect(NativeUrlPolicy.safePackage("org.videolan.vlc"))
expect(!NativeUrlPolicy.safePackage("bad;package"))
expect(!NativeUrlPolicy.allowsSharedFilename("update.APK"))
expect(!NativeUrlPolicy.allowsSharedFilename("installer.msi"))
expect(NativeUrlPolicy.allowsSharedFilename("episode.mkv"))
expect(NativeUrlPolicy.allowsSharedFilename("subtitle.vtt"))
val first = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("title" to "Film", "playing" to true, "positionMs" to -20, "durationMs" to 100))
expect(first.positionMs == 0L && first.playing && first.title == "Film")
val next = org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("positionMs" to 999), first)
expect(next.positionMs == 100L && next.durationMs == 100L && next.title == "Film")
expect(org.reelbox.reelbox.PlaybackSnapshot.from(mapOf("durationMs" to -9)).durationMs == 0L)
println("Native URL policy and playback snapshots: $assertions assertions passed")
}
@@ -0,0 +1,178 @@
package org.reelbox.reelbox.tests
import org.reelbox.reelbox.PlayerControlsState
import org.reelbox.reelbox.PlayerMenuSession
import org.reelbox.reelbox.PlayerSeekMath
import java.math.BigInteger
private var passed = 0
private var failed = 0
private fun case(name: String, run: () -> Unit) {
try {
run()
passed++
println("PASS $name")
} catch (failure: Throwable) {
failed++
println("FAIL $name: ${failure.message}")
}
}
private fun expect(value: Boolean, message: String) { check(value) { message } }
fun main() {
case("playing idle hides at precisely 3000ms; progress renders preserve deadline") {
val controls = PlayerControlsState()
controls.playback(playing = true, loading = false, error = false, pip = false, now = 0)
for (now in listOf(500L, 1000L, 2000L, 2999L)) {
controls.playback(true, false, false, false, now)
controls.tick(now)
expect(controls.chromeVisible, "progress render hid controls before deadline")
}
controls.tick(3000)
expect(!controls.chromeVisible, "idle playback did not hide")
controls.playback(true, false, false, false, 3500)
expect(!controls.chromeVisible, "progress render reopened controls")
}
case("buffering does not force chrome or consume the playing deadline") {
val controls = PlayerControlsState()
controls.playback(true, true, false, false, 0)
controls.tick(5000)
expect(controls.chromeVisible, "initial buffering hid controls before actual playback")
controls.playback(true, false, false, false, 6000)
controls.tick(8999)
expect(controls.chromeVisible, "buffering consumed playback idle time")
controls.tick(9000)
expect(!controls.chromeVisible, "playback did not hide after buffering")
}
case("PiP suppresses chrome and cancels timers; exit restores controls") {
val controls = PlayerControlsState()
controls.playback(true, false, false, true, 0)
expect(!controls.chromeVisible, "PiP retained controls")
expect(controls.deadlineMs == null, "PiP retained timer")
controls.playback(true, false, false, false, 5000)
expect(controls.chromeVisible, "PiP exit did not restore controls")
}
case("error reveals recovery after idle hide") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.tick(3000)
controls.playback(false, false, true, false, 4000)
expect(controls.chromeVisible, "error recovery remained hidden")
expect(controls.deadlineMs == null, "error scheduled auto hide")
}
case("manual tap hides paused and loading controls without reopening on ticks") {
for (loading in listOf(false, true)) {
val controls = PlayerControlsState()
controls.playback(false, loading, false, false, 0)
controls.tap(100)
expect(!controls.chromeVisible, "manual tap did not hide paused/loading chrome")
controls.playback(false, loading, false, false, 1000)
expect(!controls.chromeVisible, "render reopened manually hidden controls")
controls.tap(1200)
expect(controls.chromeVisible, "manual tap did not restore controls")
}
}
case("seeking and menus hold visibility then restart idle timeout on release") {
for (menu in listOf(false, true)) {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
if (menu) controls.menu(true, 2500) else controls.seeking(true, 2500)
controls.tick(12000)
expect(controls.chromeVisible && controls.deadlineMs == null, "active interaction auto hid")
if (menu) controls.menu(false, 12000) else controls.seeking(false, 12000)
controls.tick(14999)
expect(controls.chromeVisible, "interaction release did not grant full idle interval")
controls.tick(15000)
expect(!controls.chromeVisible, "interaction release never auto hid")
}
}
case("lock suppresses controls, unlock restores, error always unlocks recovery") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.lock(true, 100)
expect(controls.locked && !controls.chromeVisible, "lock retained chrome")
controls.tap(200)
expect(!controls.chromeVisible, "tap bypassed lock")
controls.lock(false, 300)
expect(controls.chromeVisible && !controls.locked, "unlock did not restore controls")
controls.lock(true, 400)
controls.playback(false, false, true, false, 500)
expect(!controls.locked && controls.chromeVisible, "error recovery trapped behind lock")
}
case("PiP suppresses lock affordance and ignores video interaction") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.lock(true, 100)
controls.playback(true, false, false, true, 200)
controls.tap(300)
expect(!controls.chromeVisible && !controls.unlockVisible, "PiP overlay leakage")
}
case("background and release cancel scheduling; release ignores all later events") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.foreground(false, 500)
expect(controls.deadlineMs == null, "background timer remained live")
controls.foreground(true, 7000)
expect(controls.deadlineMs == 10000L, "foreground did not grant fresh idle interval")
controls.release()
controls.tap(9000)
controls.playback(true, false, true, false, 10000)
controls.foreground(true, 12000)
expect(controls.deadlineMs == null && !controls.chromeVisible, "released policy scheduled or reopened")
}
case("explicit control interaction renews timeout; overlapping holds release independently") {
val controls = PlayerControlsState()
controls.playback(true, false, false, false, 0)
controls.interact(2500)
controls.tick(3000)
expect(controls.chromeVisible, "button interaction failed to renew timeout")
controls.seeking(true, 4000)
controls.menu(true, 4200)
controls.seeking(false, 5000)
expect(controls.deadlineMs == null, "seek completion ignored open menu")
controls.menu(false, 6000)
expect(controls.deadlineMs == 9000L, "last hold release did not restart timer")
}
case("old sheet dismissal cannot end a newer sheet's visibility hold") {
val menus = PlayerMenuSession()
val old = menus.open()
val current = menus.open()
expect(!menus.close(old), "late old onDismiss cleared the new sheet hold")
expect(menus.close(current), "current sheet could not release its hold")
expect(!menus.close(current), "duplicate dismissal released a hold twice")
}
case("normal progress conversion preserves floor millisecond targets") {
expect(PlayerSeekMath.fromProgress(120_000, 2500) == 30_000L, "normal quarter seek")
expect(PlayerSeekMath.fromProgress(123_457, 3333) == 41_148L, "fractional millisecond flooring")
}
case("accepted native maximum duration reaches exact end without overflow") {
val duration = Long.MAX_VALUE / 1000L
expect(PlayerSeekMath.fromProgress(duration, 10000) == duration, "native maximum wrapped before clamp")
}
case("full Long duration progress matches arbitrary precision across boundaries") {
for (duration in listOf(0L, 1L, 9999L, 10_001L, Long.MAX_VALUE / 1000, Long.MAX_VALUE)) {
for (progress in listOf(0, 1, 3333, 5000, 9999, 10000)) {
val expected = BigInteger.valueOf(duration).multiply(BigInteger.valueOf(progress.toLong()))
.divide(BigInteger.valueOf(10000)).toLong()
expect(PlayerSeekMath.fromProgress(duration, progress) == expected,
"duration=$duration progress=$progress expected=$expected")
}
}
}
case("progress clamps invalid duration and out-of-range thumb values") {
expect(PlayerSeekMath.fromProgress(-1, 5000) == 0L, "negative duration")
expect(PlayerSeekMath.fromProgress(Long.MIN_VALUE, 10000) == 0L, "minimum duration")
expect(PlayerSeekMath.fromProgress(120_000, -1) == 0L, "negative progress")
expect(PlayerSeekMath.fromProgress(120_000, Int.MAX_VALUE) == 120_000L, "oversized progress")
}
case("relative seek saturates rather than wrapping and clamps negative positions") {
expect(PlayerSeekMath.relative(30_000, 10_000) == 40_000L, "normal forward")
expect(PlayerSeekMath.relative(30_000, -10_000) == 20_000L, "normal rewind")
expect(PlayerSeekMath.relative(Long.MAX_VALUE - 5, 10_000) == Long.MAX_VALUE, "positive overflow")
expect(PlayerSeekMath.relative(Long.MAX_VALUE, Long.MAX_VALUE) == Long.MAX_VALUE, "max delta overflow")
expect(PlayerSeekMath.relative(5, Long.MIN_VALUE) == 0L, "rewind below zero")
expect(PlayerSeekMath.relative(-5, 10) == 10L, "negative starting position")
}
println("Player controls: $passed tests passed; $failed failed")
check(failed == 0) { "$failed controls regression(s) failed" }
}
@@ -0,0 +1,353 @@
package org.reelbox.reelbox.tests
import com.sun.net.httpserver.HttpServer
import org.reelbox.reelbox.RestrictedResourceFetcher
import java.net.InetSocketAddress
import java.net.HttpURLConnection
import java.net.URL
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import javax.net.ssl.SSLHandshakeException
import java.util.concurrent.CopyOnWriteArrayList
/** Deterministic connection seam: not a device TLS or WebView simulation. */
private class FixtureConnection(
url: URL,
private val code: Int = 200,
private val fields: Map<String, List<String>> = emptyMap(),
private val payload: InputStream = ByteArrayInputStream("public".toByteArray()),
private val failure: IOException? = null,
private val phrase: String = "",
) : HttpURLConnection(url) {
var closed = false
var statusReads = 0
override fun connect() {}
override fun disconnect() { closed = true }
override fun usingProxy() = false
override fun getResponseCode(): Int {
check(!instanceFollowRedirects) { "Automatic redirects enabled" }
check(connectTimeout in 1..30_000 && readTimeout in 1..30_000) { "Unbounded network wait" }
statusReads++
failure?.let { throw it }
return code
}
override fun getResponseMessage(): String = phrase
override fun getHeaderFields(): Map<String, List<String>> = fields
override fun getHeaderField(name: String): String? = fields.entries.firstOrNull { it.key.equals(name, true) }?.value?.firstOrNull()
override fun getInputStream(): InputStream = payload
override fun getErrorStream(): InputStream = payload
}
/** Real loopback HTTP; no remote hosts, accounts, Android runtime or Gradle. */
fun main(args: Array<String>) {
val selected = args.firstOrNull() ?: "all"
var passed = 0
fun test(name: String, body: () -> Unit) {
if (selected != "all" && selected != name) return
body()
passed++
println("PASS $name")
}
test("blocked_redirect") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
if (exchange.requestURI.path == "/redirect.js") {
exchange.responseHeaders.add("Location", "/news/script.js")
exchange.sendResponseHeaders(302, -1)
} else {
val bytes = "forbidden-marker".toByteArray()
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
try {
val url = "http://127.0.0.1:${server.address.port}/redirect.js"
val response = RestrictedResourceFetcher().fetch(url)
response.body.close()
check(response.statusCode == 403) { "Forbidden redirect must fail closed" }
check(hits == listOf("/redirect.js")) { "Forbidden target requested: $hits" }
} finally { server.stop(0) }
}
test("allowed_redirect_metadata") {
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
if (exchange.requestURI.path == "/entry.js") {
exchange.responseHeaders.add("Location", "./assets/player.js")
exchange.sendResponseHeaders(307, -1)
} else {
val bytes = "public-player".toByteArray()
exchange.responseHeaders.add("Content-Type", "application/javascript; charset=\"UTF-8\"")
exchange.responseHeaders.add("Access-Control-Allow-Origin", "https://embed.example.org")
exchange.sendResponseHeaders(200, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
exchange.close()
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}"
val response = RestrictedResourceFetcher().fetch("$base/entry.js")
check(response.statusCode == 200 && response.reasonPhrase.isNotBlank())
check(response.mimeType == "application/javascript" && response.encoding == "UTF-8")
check(response.effectiveUrl == "$base/assets/player.js")
check(response.headers.entries.any { it.key.equals("Access-Control-Allow-Origin", true) && it.value == "https://embed.example.org" })
check(response.body.use { String(it.readBytes()) } == "public-player")
for (path in listOf("/collections/private.m3u8", "/episodes/playlist.m3u8")) {
val playlist = RestrictedResourceFetcher().fetch("$base$path")
check(playlist.statusCode == 200 && playlist.body.use { String(it.readBytes()) } == "public-player")
}
} finally { server.stop(0) }
}
test("redirect_boundaries") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
val path = exchange.requestURI.path
hits.add(path)
val target = when (path) {
"/loop.js" -> "/loop.js"
"/malformed.js" -> "http://[invalid"
"/empty.js" -> null
"/chain.js" -> "/second.js"
"/second.js" -> "/%256eews/script.js"
else -> "/news/script.js"
}
if (target != null) exchange.responseHeaders.add("Location", target)
exchange.sendResponseHeaders(path.removePrefix("/code-").toIntOrNull() ?: 302, -1)
exchange.close()
}
server.start()
try {
val fetcher = RestrictedResourceFetcher()
val base = "http://127.0.0.1:${server.address.port}"
val loop = fetcher.fetch("$base/loop.js")
loop.body.close()
check(loop.statusCode == 508 && hits.size == 6) { "Redirect budget must be five: ${loop.statusCode}, ${hits.size}" }
for (path in listOf("malformed.js", "empty.js")) {
val response = fetcher.fetch("$base/$path")
response.body.close()
check(response.statusCode == 502)
}
hits.clear()
val chain = fetcher.fetch("$base/chain.js")
chain.body.close()
check(chain.statusCode == 403 && hits == listOf("/chain.js", "/second.js"))
for (code in listOf(301, 302, 303, 307, 308)) {
hits.clear()
val response = fetcher.fetch("$base/code-$code")
response.body.close()
check(response.statusCode == 403 && hits == listOf("/code-$code"))
}
hits.clear()
for (url in listOf("not a URI", "$base/news/script.js", "file:///unreadable", "content://unreadable", "data:text/plain,test")) {
val response = fetcher.fetch(url)
response.body.close()
check(response.statusCode == 403)
}
check(hits.isEmpty())
} finally { server.stop(0) }
}
test("range_head_and_headers") {
val seen = CopyOnWriteArrayList<Pair<String, Map<String, List<String>>>>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
seen.add(exchange.requestMethod to exchange.requestHeaders.toMap())
if (exchange.requestURI.path == "/entry.mp4") {
exchange.responseHeaders.add("Location", "/video.mp4")
exchange.sendResponseHeaders(307, -1)
exchange.close()
return@createContext
}
exchange.responseHeaders.add("Content-Type", "video/mp4")
exchange.responseHeaders.add("Accept-Ranges", "bytes")
exchange.responseHeaders.add("Content-Range", "bytes 2-5/10")
exchange.responseHeaders.add("Set-Cookie", "fixture=public; Path=/")
exchange.responseHeaders.add("Connection", "keep-alive, X-Hop-Only")
exchange.responseHeaders.add("X-Hop-Only", "must-not-forward")
exchange.responseHeaders.add("ETag", "\"fixture-range\"")
if (exchange.requestMethod == "HEAD") {
exchange.responseHeaders.add("Content-Length", "4")
exchange.sendResponseHeaders(206, -1)
} else {
exchange.sendResponseHeaders(206, 4)
exchange.responseBody.use { it.write("2345".toByteArray()) }
}
exchange.close()
}
server.start()
try {
val fetcher = RestrictedResourceFetcher()
val url = "http://127.0.0.1:${server.address.port}/entry.mp4"
val requestHeaders = mapOf("Range" to "bytes=2-5", "If-Range" to "\"fixture-range\"",
"User-Agent" to "PublicEmbedFixture", "Authorization" to "fixture-only",
"Cookie" to "fixture=not-a-real-session", "Proxy-Authorization" to "fixture-only",
"X-Api-Key" to "fixture-only", "Accept-Encoding" to "gzip", "Host" to "wrong.invalid",
"If-None-Match" to "\"fixture\"")
for (method in listOf("GET", "HEAD")) {
val response = fetcher.fetch(url, method, requestHeaders)
check(response.statusCode == 206 && response.mimeType == "video/mp4" && response.encoding == null)
check(response.headers.entries.any { it.key.equals("Content-Range", true) && it.value == "bytes 2-5/10" })
check(response.headers.entries.any { it.key.equals("Content-Length", true) && it.value == "4" })
check(response.headers.keys.none { it.lowercase() in listOf("set-cookie", "connection", "x-hop-only", "transfer-encoding") })
check(response.body.use { String(it.readBytes()) } == if (method == "HEAD") "" else "2345")
}
check(seen.map { it.first } == listOf("GET", "GET", "HEAD", "HEAD"))
for ((_, headers) in seen) {
fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull()
check(header("Range") == "bytes=2-5" && header("If-Range") == "\"fixture-range\"")
check(header("User-Agent") == "PublicEmbedFixture" && header("Accept-Encoding") == "identity")
check(listOf("Authorization", "Cookie", "Proxy-Authorization", "X-Api-Key", "If-None-Match").all { header(it) == null })
check(header("Host") != "wrong.invalid")
}
for (method in listOf("POST", "PUT", "DELETE", "OPTIONS")) {
val response = fetcher.fetch(url, method, requestHeaders)
response.body.close()
check(response.statusCode == 405)
}
check(seen.size == 4) { "Unsupported methods must not be sent" }
} finally { server.stop(0) }
}
test("cross_origin_headers") {
val seen = CopyOnWriteArrayList<Map<String, List<String>>>()
val first = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val second = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
val origin = "http://127.0.0.1:${first.address.port}"
first.createContext("/") { exchange ->
seen.add(exchange.requestHeaders.toMap())
if (exchange.requestURI.path == "/entry.js") {
exchange.responseHeaders.add("Location", "http://localhost:${second.address.port}/middle.js")
exchange.sendResponseHeaders(302, -1)
} else {
exchange.sendResponseHeaders(200, 2)
exchange.responseBody.use { it.write("ok".toByteArray()) }
}
exchange.close()
}
second.createContext("/") { exchange ->
seen.add(exchange.requestHeaders.toMap())
exchange.responseHeaders.add("Location", "$origin/final.js")
exchange.sendResponseHeaders(308, -1)
exchange.close()
}
first.start(); second.start()
try {
val response = RestrictedResourceFetcher().fetch("$origin/entry.js", "GET", mapOf(
"Origin" to origin, "Referer" to "$origin/embed?fixture=public", "Range" to "bytes=0-1",
"Cookie" to "fixture-only", "Authorization" to "fixture-only", "X-Private" to "fixture-only"))
check(response.body.use { String(it.readBytes()) } == "ok")
check(seen.size == 3)
fun header(index: Int, name: String) = seen[index].entries.firstOrNull { it.key.equals(name, true) }?.value?.singleOrNull()
// The JDK suppresses Origin as a restricted header; Android may send it.
check(header(0, "Referer") == "$origin/embed?fixture=public") { "Initial public Referer missing: ${header(0, "Referer")}" }
for (index in 1..2) check(header(index, "Origin") == null && header(index, "Referer") == null)
for (index in 0..2) {
check(header(index, "Range") == "bytes=0-1")
check(listOf("Cookie", "Authorization", "X-Private").all { header(index, it) == null })
}
} finally { first.stop(0); second.stop(0) }
}
test("tls_downgrade_and_stream_ownership") {
val opened = mutableListOf<FixtureConnection>()
val fetcher = RestrictedResourceFetcher(openConnection = { url ->
FixtureConnection(url, 302, mapOf("Location" to listOf("http://media.example.org/video.mp4"))).also { opened.add(it) }
})
val downgrade = fetcher.fetch("https://media.example.org/entry")
downgrade.body.close()
check(downgrade.statusCode == 403 && opened.size == 1 && opened.single().closed)
val mixed = fetcher.fetch("http://media.example.org/video.mp4", secureContext = true)
mixed.body.close()
check(mixed.statusCode == 403 && opened.size == 1)
val tlsConnection = FixtureConnection(URL("https://media.example.org/video.mp4"), failure = SSLHandshakeException("fixture untrusted peer"))
val tls = RestrictedResourceFetcher(openConnection = { tlsConnection }).fetch(tlsConnection.url.toString())
tls.body.close()
check(tls.statusCode == 502 && tlsConnection.closed && tlsConnection.statusReads == 1)
var reads = 0
var closes = 0
val payload = object : InputStream() {
override fun read(): Int { reads++; return if (reads == 1) 65 else -1 }
override fun close() { closes++ }
}
val connection = FixtureConnection(URL("https://media.example.org/video.mp4"), payload = payload)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
check(response.statusCode == 200 && reads == 0 && !connection.closed) { "Body was buffered or closed before streaming" }
check(response.reasonPhrase.isNotBlank())
check(response.body.read() == 65)
response.body.close()
check(connection.closed && closes == 1)
}
test("android_status_and_transport_failures") {
val invalidPhrase = FixtureConnection(URL("https://media.example.org/player.js"), phrase = "OK\r\nfixture\u00e9")
val safePhrase = RestrictedResourceFetcher(openConnection = { invalidPhrase }).fetch(invalidPhrase.url.toString())
safePhrase.body.close()
check(safePhrase.reasonPhrase.isNotBlank() && safePhrase.reasonPhrase.all { it.code in 32..126 })
// WebResourceResponse accepts 100..299/400..599, never 3xx or invalid codes.
for (code in listOf(-1, 99, 300, 304, 305, 306, 399, 600)) {
val connection = FixtureConnection(URL("https://media.example.org/player.js"), code)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
response.body.close()
check(response.statusCode == 502 && connection.closed) { "Invalid WebView status escaped: $code -> ${response.statusCode}" }
}
for (failure in listOf(IOException("fixture connection failure"), java.net.SocketTimeoutException("fixture timeout"))) {
val connection = FixtureConnection(URL("https://media.example.org/player.js"), failure = failure)
val response = RestrictedResourceFetcher(openConnection = { connection }).fetch(connection.url.toString())
check(response.statusCode == 502 && response.body.use { it.read() } == -1 && connection.closed)
}
}
test("access_denial_passthrough") {
val hits = CopyOnWriteArrayList<String>()
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
val code = exchange.requestURI.path.drop(1).toInt()
val bytes = "fixture-denied-$code".toByteArray()
exchange.responseHeaders.add("Content-Type", "text/plain; charset=UTF-8")
exchange.responseHeaders.add("WWW-Authenticate", "Basic realm=fixture")
exchange.sendResponseHeaders(code, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
exchange.close()
}
server.start()
try {
for (code in listOf(401, 403, 451, 404, 416)) {
val response = RestrictedResourceFetcher().fetch("http://127.0.0.1:${server.address.port}/$code")
check(response.statusCode == code && response.mimeType == "text/plain" && response.encoding == "UTF-8")
check(response.body.use { String(it.readBytes()) } == "fixture-denied-$code")
}
check(hits == listOf("/401", "/403", "/451", "/404", "/416")) { "Access denials must not cause retries: $hits" }
} finally { server.stop(0) }
}
test("excluded_redirect_destinations") {
val hits = CopyOnWriteArrayList<String>()
var destination = ""
val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0)
server.createContext("/") { exchange ->
hits.add(exchange.requestURI.path)
exchange.responseHeaders.add("Location", destination)
exchange.sendResponseHeaders(302, -1)
exchange.close()
}
server.start()
try {
val base = "http://127.0.0.1:${server.address.port}"
for (target in listOf("/news/script.js", "/support/help", "/updates/latest", "/setup.apk",
"/melodybox/embed", "/%2575pdater/latest", "https://t.me/fixture", "/live/channel.m3u8",
"/channels/example", "/public-playlists/items", "/public_playlists/items", "/?action=showlist")) {
hits.clear()
destination = if (target.startsWith("/")) "$base$target" else target
for (method in listOf("GET", "HEAD")) {
val response = RestrictedResourceFetcher().fetch("$base/entry.js", method)
response.body.close()
check(response.statusCode == 403) { "Excluded redirect permitted: $target ($method)" }
}
check(hits == listOf("/entry.js", "/entry.js")) { "Excluded target contacted: $target -> $hits" }
}
} finally { server.stop(0) }
}
check(passed > 0) { "Unknown case: $selected" }
println("Restricted resource fetch: $passed cases passed")
}
+31
View File
@@ -0,0 +1,31 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
// Beta packaging on the constrained host skips automatic release-lint
// tasks in dependencies too. Standalone lintRelease remains a separate gate.
tasks.configureEach {
if (name.contains("lintVital", ignoreCase = true)) {
enabled = false
}
}
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx512m -XX:MaxMetaspaceSize=512m -XX:ReservedCodeCacheSize=48m -XX:+UseSerialGC -XX:+ExitOnOutOfMemoryError
org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.workers.max=1
kotlin.compiler.execution.strategy=in-process
android.useAndroidX=true
android.newDsl=false
android.builtInKotlin=false
disable-abi-filtering=true
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
Vendored Executable
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
+90
View File
@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
+501
View File
@@ -0,0 +1,501 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 2.1, February 1999
Copyright (C) 1991, 1999 Free Software Foundation, Inc.
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
[This is the first released version of the Lesser GPL. It also counts
as the successor of the GNU Library Public License, version 2, hence
the version number 2.1.]
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
Licenses are intended to guarantee your freedom to share and change
free software--to make sure the software is free for all its users.
This license, the Lesser General Public License, applies to some
specially designated software packages--typically libraries--of the
Free Software Foundation and other authors who decide to use it. You
can use it too, but we suggest you first think carefully about whether
this license or the ordinary General Public License is the better
strategy to use in any particular case, based on the explanations below.
When we speak of free software, we are referring to freedom of use,
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 and use pieces of
it in new free programs; and that you are informed that you can do
these things.
To protect your rights, we need to make restrictions that forbid
distributors to deny you these rights or to ask you to surrender these
rights. These restrictions translate to certain responsibilities for
you if you distribute copies of the library or if you modify it.
For example, if you distribute copies of the library, whether gratis
or for a fee, you must give the recipients all the rights that we gave
you. You must make sure that they, too, receive or can get the source
code. If you link other code with the library, you must provide
complete object files to the recipients, so that they can relink them
with the library after making changes to the library and recompiling
it. And you must show them these terms so they know their rights.
We protect your rights with a two-step method: (1) we copyright the
library, and (2) we offer you this license, which gives you legal
permission to copy, distribute and/or modify the library.
To protect each distributor, we want to make it very clear that
there is no warranty for the free library. Also, if the library is
modified by someone else and passed on, the recipients should know
that what they have is not the original version, so that the original
author's reputation will not be affected by problems that might be
introduced by others.
Finally, software patents pose a constant threat to the existence of
any free program. We wish to make sure that a company cannot
effectively restrict the users of a free program by obtaining a
restrictive license from a patent holder. Therefore, we insist that
any patent license obtained for a version of the library must be
consistent with the full freedom of use specified in this license.
Most GNU software, including some libraries, is covered by the
ordinary GNU General Public License. This license, the GNU Lesser
General Public License, applies to certain designated libraries, and
is quite different from the ordinary General Public License. We use
this license for certain libraries in order to permit linking those
libraries into non-free programs.
When a program is linked with a library, whether statically or using
a shared library, the combination of the two is legally speaking a
combined work, a derivative of the original library. The ordinary
General Public License therefore permits such linking only if the
entire combination fits its criteria of freedom. The Lesser General
Public License permits more lax criteria for linking other code with
the library.
We call this license the "Lesser" General Public License because it
does Less to protect the user's freedom than the ordinary General
Public License. It also provides other free software developers Less
of an advantage over competing non-free programs. These disadvantages
are the reason we use the ordinary General Public License for many
libraries. However, the Lesser license provides advantages in certain
special circumstances.
For example, on rare occasions, there may be a special need to
encourage the widest possible use of a certain library, so that it becomes
a de-facto standard. To achieve this, non-free programs must be
allowed to use the library. A more frequent case is that a free
library does the same job as widely used non-free libraries. In this
case, there is little to gain by limiting the free library to free
software only, so we use the Lesser General Public License.
In other cases, permission to use a particular library in non-free
programs enables a greater number of people to use a large body of
free software. For example, permission to use the GNU C Library in
non-free programs enables many more people to use the whole GNU
operating system, as well as its variant, the GNU/Linux operating
system.
Although the Lesser General Public License is Less protective of the
users' freedom, it does ensure that the user of a program that is
linked with the Library has the freedom and the wherewithal to run
that program using a modified version of the Library.
The precise terms and conditions for copying, distribution and
modification follow. Pay close attention to the difference between a
"work based on the library" and a "work that uses the library". The
former contains code derived from the library, whereas the latter must
be combined with the library in order to run.
GNU LESSER GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License Agreement applies to any software library or other
program which contains a notice placed by the copyright holder or
other authorized party saying it may be distributed under the terms of
this Lesser General Public License (also called "this License").
Each licensee is addressed as "you".
A "library" means a collection of software functions and/or data
prepared so as to be conveniently linked with application programs
(which use some of those functions and data) to form executables.
The "Library", below, refers to any such software library or work
which has been distributed under these terms. A "work based on the
Library" means either the Library or any derivative work under
copyright law: that is to say, a work containing the Library or a
portion of it, either verbatim or with modifications and/or translated
straightforwardly into another language. (Hereinafter, translation is
included without limitation in the term "modification".)
"Source code" for a work means the preferred form of the work for
making modifications to it. For a library, 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 library.
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running a program using the Library is not restricted, and output from
such a program is covered only if its contents constitute a work based
on the Library (independent of the use of the Library in a tool for
writing it). Whether that is true depends on what the Library does
and what the program that uses the Library does.
1. You may copy and distribute verbatim copies of the Library's
complete 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 distribute a copy of this License along with the
Library.
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 Library or any portion
of it, thus forming a work based on the Library, 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) The modified work must itself be a software library.
b) You must cause the files modified to carry prominent notices
stating that you changed the files and the date of any change.
c) You must cause the whole of the work to be licensed at no
charge to all third parties under the terms of this License.
d) If a facility in the modified Library refers to a function or a
table of data to be supplied by an application program that uses
the facility, other than as an argument passed when the facility
is invoked, then you must make a good faith effort to ensure that,
in the event an application does not supply such function or
table, the facility still operates, and performs whatever part of
its purpose remains meaningful.
(For example, a function in a library to compute square roots has
a purpose that is entirely well-defined independent of the
application. Therefore, Subsection 2d requires that any
application-supplied function or table used by this function must
be optional: if the application does not supply it, the square
root function must still compute square roots.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Library,
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 Library, 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 Library.
In addition, mere aggregation of another work not based on the Library
with the Library (or with a work based on the Library) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may opt to apply the terms of the ordinary GNU General Public
License instead of this License to a given copy of the Library. To do
this, you must alter all the notices that refer to this License, so
that they refer to the ordinary GNU General Public License, version 2,
instead of to this License. (If a newer version than version 2 of the
ordinary GNU General Public License has appeared, then you can specify
that version instead if you wish.) Do not make any other change in
these notices.
Once this change is made in a given copy, it is irreversible for
that copy, so the ordinary GNU General Public License applies to all
subsequent copies and derivative works made from that copy.
This option is useful when you wish to copy part of the code of
the Library into a program that is not a library.
4. You may copy and distribute the Library (or a portion or
derivative of it, under Section 2) in object code or executable form
under the terms of Sections 1 and 2 above provided that you 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.
If distribution of 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 satisfies the requirement to
distribute the source code, even though third parties are not
compelled to copy the source along with the object code.
5. A program that contains no derivative of any portion of the
Library, but is designed to work with the Library by being compiled or
linked with it, is called a "work that uses the Library". Such a
work, in isolation, is not a derivative work of the Library, and
therefore falls outside the scope of this License.
However, linking a "work that uses the Library" with the Library
creates an executable that is a derivative of the Library (because it
contains portions of the Library), rather than a "work that uses the
library". The executable is therefore covered by this License.
Section 6 states terms for distribution of such executables.
When a "work that uses the Library" uses material from a header file
that is part of the Library, the object code for the work may be a
derivative work of the Library even though the source code is not.
Whether this is true is especially significant if the work can be
linked without the Library, or if the work is itself a library. The
threshold for this to be true is not precisely defined by law.
If such an object file uses only numerical parameters, data
structure layouts and accessors, and small macros and small inline
functions (ten lines or less in length), then the use of the object
file is unrestricted, regardless of whether it is legally a derivative
work. (Executables containing this object code plus portions of the
Library will still fall under Section 6.)
Otherwise, if the work is a derivative of the Library, you may
distribute the object code for the work under the terms of Section 6.
Any executables containing that work also fall under Section 6,
whether or not they are linked directly with the Library itself.
6. As an exception to the Sections above, you may also combine or
link a "work that uses the Library" with the Library to produce a
work containing portions of the Library, and distribute that work
under terms of your choice, provided that the terms permit
modification of the work for the customer's own use and reverse
engineering for debugging such modifications.
You must give prominent notice with each copy of the work that the
Library is used in it and that the Library and its use are covered by
this License. You must supply a copy of this License. If the work
during execution displays copyright notices, you must include the
copyright notice for the Library among them, as well as a reference
directing the user to the copy of this License. Also, you must do one
of these things:
a) Accompany the work with the complete corresponding
machine-readable source code for the Library including whatever
changes were used in the work (which must be distributed under
Sections 1 and 2 above); and, if the work is an executable linked
with the Library, with the complete machine-readable "work that
uses the Library", as object code and/or source code, so that the
user can modify the Library and then relink to produce a modified
executable containing the modified Library. (It is understood
that the user who changes the contents of definitions files in the
Library will not necessarily be able to recompile the application
to use the modified definitions.)
b) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (1) uses at run time a
copy of the library already present on the user's computer system,
rather than copying library functions into the executable, and (2)
will operate properly with a modified version of the library, if
the user installs one, as long as the modified version is
interface-compatible with the version that the work was made with.
c) Accompany the work with a written offer, valid for at
least three years, to give the same user the materials
specified in Subsection 6a, above, for a charge no more
than the cost of performing this distribution.
d) If distribution of the work is made by offering access to copy
from a designated place, offer equivalent access to copy the above
specified materials from the same place.
e) Verify that the user has already received a copy of these
materials or that you have already sent this user a copy.
For an executable, the required form of the "work that uses the
Library" must include any data and utility programs needed for
reproducing the executable from it. However, as a special exception,
the materials to be distributed need not include anything that is
normally distributed (in either source or binary form) with the major
components (compiler, kernel, and so on) of the operating system on
which the executable runs, unless that component itself accompanies
the executable.
It may happen that this requirement contradicts the license
restrictions of other proprietary libraries that do not normally
accompany the operating system. Such a contradiction means you cannot
use both them and the Library together in an executable that you
distribute.
7. You may place library facilities that are a work based on the
Library side-by-side in a single library together with other library
facilities not covered by this License, and distribute such a combined
library, provided that the separate distribution of the work based on
the Library and of the other library facilities is otherwise
permitted, and provided that you do these two things:
a) Accompany the combined library with a copy of the same work
based on the Library, uncombined with any other library
facilities. This must be distributed under the terms of the
Sections above.
b) Give prominent notice with the combined library of the fact
that part of it is a work based on the Library, and explaining
where to find the accompanying uncombined form of the same work.
8. You may not copy, modify, sublicense, link with, or distribute
the Library except as expressly provided under this License. Any
attempt otherwise to copy, modify, sublicense, link with, or
distribute the Library 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.
9. 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 Library or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Library (or any work based on the
Library), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Library or works based on it.
10. Each time you redistribute the Library (or any work based on the
Library), the recipient automatically receives a license from the
original licensor to copy, distribute, link with or modify the Library
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 with
this License.
11. 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 Library at all. For example, if a patent
license would not permit royalty-free redistribution of the Library 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 Library.
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.
12. If the distribution and/or use of the Library is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Library 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.
13. The Free Software Foundation may publish revised and/or new
versions of the Lesser 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 Library
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 Library does not specify a
license version number, you may choose any version ever published by
the Free Software Foundation.
14. If you wish to incorporate parts of the Library into other free
programs whose distribution conditions are incompatible with these,
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
15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE LIBRARY "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
LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. 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 LIBRARY 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
LIBRARY (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 LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Libraries
If you develop a new library, and you want it to be of the greatest
possible use to the public, we recommend making it free software that
everyone can redistribute and change. You can do so by permitting
redistribution under these terms (or, alternatively, under the terms of the
ordinary General Public License).
To apply these terms, attach the following notices to the library. It is
safest to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least the
"copyright" line and a pointer to where the full notice is found.
<one line to give the library's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 2.1 of the License, or (at your option) any later version.
This library 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
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the library, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
library `Frob' (a library for tweaking knobs) written by James Random Hacker.
<signature of Moe Ghoul>, 1 April 1990
Moe Ghoul, President of Vice
That's all there is to it!
+20
View File
@@ -0,0 +1,20 @@
LibVLC for Android 3.7.7
Copyright VideoLAN and the LibVLC authors
Published artifact: org.videolan.android:libvlc-all:3.7.7
Android bindings are licensed under GNU LGPL version 2.1; the license text accompanies this notice. Native components retain their upstream licenses. ReelBox is distributed under GPL version 3.
The LibVLC binary is unmodified and dynamically linked. ReelBox is an independent app, not VideoLAN VLC for Android or KMPlayer. No VideoLAN artwork or branding is included.
ReelBox source and Gradle build files permit rebuilding this app and replacing the LibVLC dependency; no signing or installation restriction is imposed on modified builds. Use your own Android signing key for a rebuilt application.
Pinned upstream source and build instructions:
https://github.com/videolan/vlc-android/tree/libvlc-3.7.7
Tag commit: 7a7c8f14be6759f9bb1b9a934fd086259473e20e
This upstream build pins libvlcjni commit 0b8dc65efb203c86a0476bc337ddd99ecf1c0ef6:
https://code.videolan.org/videolan/libvlcjni/-/tree/0b8dc65efb203c86a0476bc337ddd99ecf1c0ef6
The upstream buildsystem/get-vlc.sh pins the native VLC source and the build tree contains contribution recipes and patches.
Published Java sources:
https://repo.maven.apache.org/maven2/org/videolan/android/libvlc-all/3.7.7/libvlc-all-3.7.7-sources.jar
ReelBox's source distribution includes the published Java sources and exact Maven POM under third_party/libvlc. Its dependency and native build instructions are documented in third_party/libvlc/NOTICE.txt.
This software is provided without warranty. Retain all license and copyright notices when redistributing.
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'data/api_service.dart';
import 'state/app_state.dart';
import 'ui/catalog.dart';
import 'ui/library.dart';
class MyApp extends StatefulWidget {
const MyApp({super.key, this.state});
final AppState? state;
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late final AppState state = widget.state ?? AppState(api: ApiService());
@override
void dispose() {
if (widget.state == null) state.dispose();
super.dispose();
}
ThemeData theme(Brightness brightness) {
final dark = brightness == Brightness.dark;
final colors = ColorScheme.fromSeed(
seedColor: const Color(0xFFFF675C),
brightness: brightness,
primary: dark ? const Color(0xFFFF8276) : const Color(0xFFBC3D34),
surface: dark ? const Color(0xFF0B0D12) : const Color(0xFFFAF8F5),
);
return ThemeData(
useMaterial3: true,
colorScheme: colors,
scaffoldBackgroundColor: colors.surface,
visualDensity: VisualDensity.standard,
appBarTheme: AppBarTheme(
centerTitle: false,
elevation: 0,
scrolledUnderElevation: 0,
backgroundColor: colors.surface,
titleTextStyle: TextStyle(
fontFamily: 'Roboto',
color: colors.onSurface,
fontSize: 23,
fontWeight: FontWeight.w700,
),
),
cardTheme: CardThemeData(
elevation: 0,
color: colors.surfaceContainerLow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colors.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: BorderSide.none,
),
),
chipTheme: ChipThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
dividerTheme: DividerThemeData(
color: colors.outlineVariant.withValues(alpha: .45),
space: 1,
),
navigationBarTheme: NavigationBarThemeData(
height: 74,
backgroundColor: colors.surfaceContainerLow,
indicatorColor: colors.primary.withValues(alpha: .17),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
@override
Widget build(BuildContext context) => AppScope(
notifier: state,
child: AnimatedBuilder(
animation: state,
builder: (_, _) => MaterialApp(
title: 'Reelbox',
debugShowCheckedModeBanner: false,
theme: theme(Brightness.light),
darkTheme: theme(Brightness.dark),
themeMode: switch (state.prefs.theme) {
'light' => ThemeMode.light,
'system' => ThemeMode.system,
_ => ThemeMode.dark,
},
locale: Locale(state.locale),
supportedLocales: const [Locale('en'), Locale('fa')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
home: state.prefs.introCompleted
? const _AppShell()
: const FirstRunIntroPage(),
),
),
);
}
class _AppShell extends StatefulWidget {
const _AppShell();
@override
State<_AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<_AppShell> {
int selected = 0;
final List<Widget?> pages = [const DiscoverPage(), null, null, null, null];
Widget page(int index) => switch (index) {
0 => const DiscoverPage(),
1 => const BrowsePage(),
2 => const SearchPage(),
3 => const LibraryPage(),
_ => const MorePage(),
};
@override
Widget build(BuildContext context) {
final revision = AppScope.of(context).apiRevision;
return Scaffold(
body: IndexedStack(
index: selected,
children: [
for (var i = 0; i < pages.length; i++)
KeyedSubtree(
key: ValueKey(i < 3 ? 'catalog-$i-$revision' : 'local-$i'),
child: pages[i] ?? const SizedBox.shrink(),
),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: selected,
onDestinationSelected: (index) => setState(() {
selected = index;
pages[index] ??= page(index);
}),
destinations: [
NavigationDestination(
icon: const Icon(Icons.explore_outlined),
selectedIcon: const Icon(Icons.explore),
label: tr(context, 'Discover', 'کشف'),
),
NavigationDestination(
icon: const Icon(Icons.grid_view_outlined),
selectedIcon: const Icon(Icons.grid_view_rounded),
label: tr(context, 'Browse', 'مرور'),
),
NavigationDestination(
icon: const Icon(Icons.search_rounded),
label: tr(context, 'Search', 'جستجو'),
),
NavigationDestination(
icon: const Icon(Icons.video_library_outlined),
selectedIcon: const Icon(Icons.video_library_rounded),
label: tr(context, 'Library', 'کتابخانه'),
),
NavigationDestination(
icon: const Icon(Icons.tune_rounded),
label: tr(context, 'More', 'بیشتر'),
),
],
),
);
}
}
+98
View File
@@ -0,0 +1,98 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Runs native/local initialization behind a visible, recoverable startup view.
/// Raw exception strings are never rendered: they can include file paths, remote
/// service data or removed vendor text.
class ReelboxBootstrap extends StatefulWidget {
const ReelboxBootstrap({
super.key,
required this.initialize,
required this.builder,
});
final Future<void> Function() initialize;
final WidgetBuilder builder;
@override
State<ReelboxBootstrap> createState() => _ReelboxBootstrapState();
}
class _ReelboxBootstrapState extends State<ReelboxBootstrap> {
bool _ready = false, _failed = false;
int _generation = 0;
@override
void initState() {
super.initState();
unawaited(_start());
}
Future<void> _start() async {
final generation = ++_generation;
setState(() {
_ready = false;
_failed = false;
});
try {
await widget.initialize();
if (mounted && generation == _generation) setState(() => _ready = true);
} catch (_) {
if (mounted && generation == _generation) setState(() => _failed = true);
}
}
@override
void dispose() {
_generation++;
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_ready) return widget.builder(context);
return MaterialApp(
title: 'Reelbox',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
colorSchemeSeed: const Color(0xFFBC3D34),
),
home: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.local_movies_outlined, size: 48),
const SizedBox(height: 24),
const Text('Reelbox', style: TextStyle(fontSize: 28)),
const SizedBox(height: 24),
if (!_failed) ...[
const CircularProgressIndicator(),
const SizedBox(height: 24),
const Text('Opening your library'),
] else ...[
const Text(
'Reelbox could not start',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
const Text(
'Your library has not been reset. Try starting again.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton(
onPressed: () => unawaited(_start()),
child: const Text('Try again'),
),
],
],
),
),
),
),
);
}
}
+643
View File
@@ -0,0 +1,643 @@
import 'dart:collection';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models.dart';
import 'content_policy.dart';
List<dynamic> _array(dynamic raw) {
if (raw == null) return [];
if (raw is List) return raw;
if (raw is Map) {
for (final key in ['posters', 'data', 'result', 'items']) {
if (raw[key] is List) return raw[key] as List;
}
}
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
List<Map<String, dynamic>> _maps(dynamic raw) =>
_array(raw)
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList();
Map<String, dynamic> _object(dynamic raw) {
if (raw is Map) return Map<String, dynamic>.from(raw);
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
List<TitleRecord> _titles(dynamic raw) =>
_maps(raw)
.where(ContentPolicy.allowsTitleJson)
.map(TitleRecord.fromJson)
.where((title) => title.id > 0 && title.title.isNotEmpty)
.toList();
/// Safe error suitable for presentation. Never holds request URLs or form data.
class ApiException implements Exception {
final String message;
final int? statusCode;
const ApiException(this.message, {this.statusCode});
@override
String toString() => message;
}
/// A single raw server page, compatible with existing List-based callers.
/// Visible items may be empty even when the raw page has a continuation.
class CatalogPage extends ListBase<TitleRecord> {
CatalogPage(Iterable<TitleRecord> items, {required this.nextPage})
: _items = List.unmodifiable(items);
final List<TitleRecord> _items;
final int? nextPage;
@override
int get length => _items.length;
@override
set length(int value) => throw UnsupportedError('Read-only page');
@override
TitleRecord operator [](int index) => _items[index];
@override
void operator []=(int index, TitleRecord value) =>
throw UnsupportedError('Read-only page');
}
class ApiService {
// Iran accepts a public route marker, not an upstream application secret.
static const sharedKey = 'public';
// Compatibility only; actor queries no longer send a key.
static const actorKey = 'public';
// Release AOT also uses --obfuscate. This removes an obvious literal from
// binary string scans, not a secret: network destinations remain observable.
static final String iranBaseUrl = String.fromCharCodes(
[
50,
46,
46,
42,
41,
96,
117,
117,
40,
63,
63,
54,
56,
53,
34,
116,
62,
59,
40,
46,
46,
35,
116,
46,
53,
42,
117,
59,
42,
51,
117,
].map((value) => value ^ 0x5a),
);
static final String defaultBaseUrl = iranBaseUrl;
static const _selectionKey = 'reelbox.api-selection.v1';
// One production metadata origin; loopback remains an explicit test seam.
static final _serviceOrigins = {Uri.parse(iranBaseUrl).origin};
static const _legacyHosts = {
'simbaapp.info',
'winbedrives.com',
'boxlimon.com',
'gitcss.info',
};
final Dio _dio;
// Only pending idempotent metadata reads are shared. Never retain detail,
// source or catalog responses after completion: existing refreshes stay fresh.
final _pendingMetadata = <String, Future<dynamic>>{};
static const _maxPendingMetadata = 64;
static bool _shareableMetadata(Uri uri) {
if (RegExp(
r'^/api/(first/public/|(genre|country)/all/public/|movie/by/[0-9]+/public/|movie/by/actor/[0-9]+/public/|season/by/serie/[0-9]+/public/|role/by/poster/[0-9]+/public/|subtitles/by/(movie|episode)/[0-9]+/public/|(movie|serie|poster)/by/filtres/[^?]+/public/)$',
).hasMatch(uri.path)) {
// Unknown query actions can change the semantics of an otherwise known
// route. Keep them outside the sharing allowlist.
return !uri.queryParametersAll.containsKey('action');
}
return false;
}
final Future<SharedPreferences> Function() _preferences;
bool _cacheRead = false;
Future<void>? _cacheRestore;
static const _cacheKey = 'reelbox.trusted-api.v1';
String _baseUrl;
int _selectionGeneration = 0;
Future<void> _selectionWrite = Future<void>.value();
ApiService({
Dio? dio,
String? baseUrl,
Future<SharedPreferences> Function()? preferences,
}) : _dio = dio ?? Dio(),
_preferences = preferences ?? SharedPreferences.getInstance,
_baseUrl = _validatedBase(baseUrl ?? defaultBaseUrl) {
// Do not inherit upstream credentials or query defaults from injected Dio.
_dio.options = BaseOptions(
connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 25),
sendTimeout: const Duration(seconds: 12),
followRedirects: false,
maxRedirects: 0,
responseType: ResponseType.plain,
headers: {'Accept': 'application/json'},
);
}
String get baseUrl => _baseUrl;
String get hostRoot => Uri.parse(_baseUrl).origin;
static bool _loopback(Uri uri) =>
{'localhost', '127.0.0.1', '::1', '[::1]'}.contains(uri.host);
static String _validatedBase(String value) {
try {
if (value != value.trim() ||
!RegExp(r'^https?://[^/]+/api/?$').hasMatch(value)) {
throw const FormatException();
}
final uri = Uri.parse(value);
final trusted =
uri.scheme == 'https' && _serviceOrigins.contains(uri.origin);
final local =
_loopback(uri) && (uri.scheme == 'http' || uri.scheme == 'https');
if ((!trusted && !local) ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
!{'/api', '/api/'}.contains(uri.path)) {
throw const FormatException();
}
return uri.replace(path: '/api/').toString();
} on FormatException {
throw const ApiException('Use a trusted HTTPS API server.');
}
}
Future<void> setBaseUrl(String url) async {
final validated = _validatedBase(url);
++_selectionGeneration;
final write = _selectionWrite.then((_) async {
final local = _loopback(Uri.parse(validated));
if (!local) {
final stored = await (await _preferences()).setString(
_selectionKey,
'primary',
);
if (!stored) {
throw const ApiException('Could not save the API selection.');
}
}
_cacheRead = true;
_baseUrl = validated;
});
_selectionWrite = write.catchError((Object _) {});
return write;
}
// `discover` remains source-compatible; remote provider discovery is retired.
Future<void> initialize({bool discover = true}) async {
await _restoreCachedBase();
}
Future<void> _restoreCachedBase() {
if (_cacheRead || _loopback(Uri.parse(_baseUrl))) {
return Future<void>.value();
}
final running = _cacheRestore;
if (running != null) return running;
late final Future<void> work;
work =
(() async {
final generation = _selectionGeneration;
try {
final prefs = await _preferences();
if (_selectionGeneration != generation) return;
// Stored provider choices and old discovery caches are migration
// inputs, never permission to contact another production origin.
await prefs.setString(_selectionKey, 'primary');
if (prefs.containsKey(_cacheKey)) {
await prefs.remove(_cacheKey);
}
} catch (_) {
// Storage failure must not change the Iran-only in-memory default.
}
})().whenComplete(() {
_cacheRead = true;
if (identical(_cacheRestore, work)) _cacheRestore = null;
});
_cacheRestore = work;
return work;
}
Uri _resolve(String path, {bool allowLegacy = true}) {
try {
if (path != path.trim() || path.startsWith('//')) {
throw const FormatException();
}
var rawPath = path.split('?').first;
for (var i = 0; i < 3; i++) {
if (RegExp(r'[\\\x00-\x20\x7f]').hasMatch(rawPath) ||
RegExp(r'(^|/)\.{1,2}(/|$)').hasMatch(rawPath)) {
throw const FormatException();
}
final decoded = Uri.decodeComponent(rawPath);
if (decoded == rawPath) break;
rawPath = decoded;
}
var uri = Uri.parse(path);
if (uri.hasFragment || uri.userInfo.isNotEmpty) {
throw const FormatException();
}
final base = Uri.parse(_baseUrl);
if (uri.hasScheme || uri.hasAuthority) {
if (!{'http', 'https'}.contains(uri.scheme)) {
throw const FormatException();
}
if (uri.origin == base.origin) {
// Current-host requests never downgrade HTTPS.
} else if (allowLegacy &&
_legacyHosts.contains(uri.host) &&
(uri.scheme == 'http' || uri.scheme == 'https') &&
((!uri.hasPort) || uri.port == 80 || uri.port == 443) &&
(uri.path.startsWith('/api-user/newapi/') ||
uri.path.startsWith('/api/'))) {
uri = Uri.parse(
'$hostRoot${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}',
);
} else {
throw const FormatException();
}
} else {
uri = base.resolve(path);
}
if (uri.origin != base.origin ||
!(uri.path.startsWith('/api/') ||
uri.path.startsWith('/api-user/'))) {
throw const FormatException();
}
return uri;
} on FormatException {
throw const ApiException('The service link is not trusted.');
}
}
List<TitleRecord> decodeTitles(dynamic raw) => _titles(raw);
Future<dynamic> _legacy(String file, Map<String, dynamic> query) async {
await _restoreCachedBase();
return _request(
'GET',
_resolve('$hostRoot/api-user/newapi/$file'),
query: query,
);
}
Future<List<TitleRecord>> _titlePage(
Future<dynamic> request,
int page,
) async {
final raw = _array(await request);
// Hidden announcements must not terminate upstream catalog pagination.
return CatalogPage(_titles(raw), nextPage: raw.isEmpty ? null : page + 1);
}
Future<List<TitleRecord>> search(
String query, {
int page = 1,
bool english = false,
}) => _titlePage(
_legacy('like.php', {
'action': english ? 'search-movie-en' : 'search-movie',
'q': query,
'pageno': page,
}),
page,
);
Future<List<TitleRecord>> recommended(int genre, {int page = 1}) async =>
_titlePage(
_legacy('filter.php', {
'action': 'pishnahad',
'garid': genre,
'pageno': page,
}),
page,
);
Future<List<TitleRecord>> bestRandom({bool byYear = false, int page = 1}) =>
_titlePage(
_legacy('like.php', {
'action': byYear ? 'best-rand-year' : 'best-rand-top',
'pageno': page,
}),
page,
);
Future<List<String>> trending() async {
final raw = _array(
await _legacy('like.php', {'action': 'search-trand', 'pageno': 1}),
);
return raw
.map(
(item) => item is String
? item
: item is Map
? item['title'] ?? item['q']
: null,
)
.whereType<String>()
.where(ContentPolicy.allowsText)
.where((title) => title.trim().isNotEmpty)
.take(14)
.toList();
}
Future<List<Map<String, dynamic>>> actors({
int page = 1,
String search = '',
}) async {
await _restoreCachedBase();
if (search.trim().isEmpty) {
return _maps(
ContentPolicy.sanitizeJson(
await _legacy('like.php', {'action': 'all-actor', 'pageno': page}),
),
);
}
// Native Retrofit evidence; the desktop like.php starsearch route currently
// returns an empty non-JSON body rather than actor results.
return _maps(
await get(
'$hostRoot/api-user/filmahang/star.php',
query: {'action': 'starsearch', 'q': search.trim(), 'pageno': page},
),
);
}
Future<List<Map<String, dynamic>>> playlists({
bool mine = false,
String userId = '',
int page = 1,
}) async {
// No-network compatibility seam for older callers. Public lists are removed.
if (mine) {
throw const ApiException('Server playlists are not part of Reelbox.');
}
return const [];
}
Future<List<Map<String, dynamic>>> playlistItems(
int listId, {
int page = 1,
}) async => const [];
Future<List<Map<String, dynamic>>> curatedLinks({bool best = false}) async {
await _restoreCachedBase();
final groups = _maps(
await _legacy(best ? 'bartar.php' : 'pishnahad.php', {}),
);
final result = <Map<String, dynamic>>[];
for (final group in groups) {
final titles = group['title'], links = group['link'];
if (titles is! List || links is! List) continue;
for (var i = 0; i < titles.length && i < links.length; i++) {
if (titles[i] is! String || links[i] is! String) continue;
if (!ContentPolicy.allowsText(titles[i] as String) ||
!ContentPolicy.allowsUrl(links[i] as String)) {
continue;
}
try {
result.add({
'title': titles[i],
'link': _resolve(links[i] as String).toString(),
});
} on ApiException {
// Never expose a server-supplied arbitrary destination as API content.
}
}
}
return result;
}
Future<List<TitleRecord>> curatedContent(String link, {int page = 1}) async {
await _restoreCachedBase();
final uri = _resolve(link);
final params = {...uri.queryParameters, 'pageno': '$page'};
return _titles(await get(uri.replace(queryParameters: params).toString()));
}
Future<Map<String, dynamic>> home() async {
final data = _object(await get('first/$sharedKey/'));
// Unknown future server fields cannot silently introduce vendor feeds.
const keys = {
'posters',
'genres',
'slides',
'actors',
'countries',
'movies',
'series',
'featured',
'popular',
'trending',
};
return {
for (final entry in data.entries)
if (keys.contains(entry.key)) entry.key: entry.value,
};
}
Future<List<Tag>> _tags(String name) async =>
_maps(await get('$name/all/$sharedKey/')).map(Tag.fromJson).toList();
Future<List<Tag>> genres() => _tags('genre');
Future<List<Tag>> countries() => _tags('country');
Future<List<Tag>> categories() async => const [];
Future<List<TitleRecord>> catalog({
String kind = 'movie',
int genre = 0,
int country = 0,
String order = 'created',
int page = 0,
}) async {
if (!{'movie', 'serie', 'series', 'mixed', 'poster'}.contains(kind) ||
page < 0) {
throw const ApiException('Unsupported catalog filter.');
}
await _restoreCachedBase();
final normalized = kind == 'series' ? 'serie' : kind;
final mixed =
normalized == 'mixed' || normalized == 'poster' || country != 0;
final route = mixed
? 'poster/by/filtres/$genre/$country/${Uri.encodeComponent(order)}/$page'
: '$normalized/by/filtres/$genre/${Uri.encodeComponent(order)}/$page';
final raw = _array(await _request('GET', _resolve('$route/$sharedKey/')));
final titles = _titles(raw);
return CatalogPage(
country != 0 && {'movie', 'serie'}.contains(normalized)
? titles.where((title) => title.type == normalized)
: titles,
nextPage: raw.isEmpty ? null : page + 1,
);
}
Future<List<TitleRecord>> channels({
int category = 0,
int country = 0,
int page = 0,
}) async => const [];
Future<TitleRecord> poster(int id) async =>
TitleRecord.fromJson(_object(await get('movie/by/$id/$sharedKey/')));
Future<TitleRecord> channel(int id) async =>
throw const ApiException('This content is not part of Reelbox.');
Future<List<SeasonRecord>> seasons(int id) async =>
_maps(await get('season/by/serie/$id/$sharedKey/'))
.map(SeasonRecord.fromJson)
.toList();
Future<List<TitleRecord>> actorTitles(int id) async =>
_titles(await get('movie/by/actor/$id/$sharedKey/'));
Future<List<Map<String, dynamic>>> roles(int posterId) async =>
_maps(await get('role/by/poster/$posterId/$sharedKey/'));
Future<List<Map<String, dynamic>>> subtitles(String kind, int id) async {
if (!{'movie', 'episode'}.contains(kind)) {
throw const ApiException('Unsupported subtitle kind.');
}
return _maps(await get('subtitles/by/$kind/$id/$sharedKey/'));
}
Future<List<Map<String, dynamic>>> comments(int posterId) async => const [];
Future<dynamic> get(String path, {Map<String, dynamic>? query}) async {
await _restoreCachedBase();
return ContentPolicy.sanitizeJson(
await _request('GET', _resolve(path), query: query),
);
}
Future<dynamic> post(String path, Map<String, dynamic> form) async {
await _restoreCachedBase();
return _request('POST', _resolve(path, allowLegacy: false), form: form);
}
Future<dynamic> _request(
String method,
Uri uri, {
Map<String, dynamic>? query,
Map<String, dynamic>? form,
bool retryTransport = true,
}) async {
final policyUris = [
uri,
for (final action in [query?['action'], form?['action']])
if (action != null) uri.replace(queryParameters: {'action': '$action'}),
];
if (policyUris.any((value) => !ContentPolicy.allowsApiPath(value)) ||
!ContentPolicy.allowsUrl(uri.toString())) {
throw const ApiException('This content is not part of Reelbox.');
}
// Key exactly the URI Dio will send, including origin, page and filters.
// Validation above always runs, including for a potential cache hit.
final requestUri = Options(method: method)
.compose(_dio.options, uri.toString(), queryParameters: query)
.uri;
if (method != 'GET' || !_shareableMetadata(requestUri)) {
return _sendRequest(method, requestUri, form: form,
retryTransport: retryTransport);
}
final key = requestUri.toString();
final pending = _pendingMetadata[key];
if (pending != null) return pending;
if (_pendingMetadata.length >= _maxPendingMetadata) {
return _sendRequest(method, requestUri,
retryTransport: retryTransport);
}
late final Future<dynamic> work;
work = _sendRequest(method, requestUri,
retryTransport: retryTransport).whenComplete(() {
if (identical(_pendingMetadata[key], work)) {
_pendingMetadata.remove(key);
}
});
_pendingMetadata[key] = work;
return work;
}
Future<dynamic> _sendRequest(
String method,
Uri uri, {
Map<String, dynamic>? form,
required bool retryTransport,
}) async {
for (var attempt = 0; ; attempt++) {
try {
final response = await _dio.request<dynamic>(
uri.toString(),
data: form,
options: Options(
method: method,
followRedirects: false,
maxRedirects: 0,
responseType: ResponseType.plain,
contentType: form == null
? null
: Headers.formUrlEncodedContentType,
),
);
final status = response.statusCode ?? 0;
if (status < 200 || status >= 300) {
throw ApiException(
'The service rejected the request.',
statusCode: status,
);
}
if (response.data is String) {
final text = (response.data as String)
.replaceFirst('\uFEFF', '')
.trim();
return jsonDecode(text);
}
return response.data;
} on DioException catch (error) {
if (attempt == 0 &&
retryTransport &&
method == 'GET' &&
error.response == null &&
const {
DioExceptionType.connectionError,
DioExceptionType.connectionTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.receiveTimeout,
}.contains(error.type)) {
// One replay of an idempotent read only. No discovery request storm,
// alternate untrusted destination, denial retry or mutation replay.
await _restoreCachedBase();
continue;
}
final code = error.response?.statusCode;
final message = switch (error.type) {
DioExceptionType.connectionTimeout ||
DioExceptionType.sendTimeout ||
DioExceptionType.receiveTimeout =>
'The service timed out. Please retry.',
DioExceptionType.connectionError =>
'Unable to connect. Check your connection and retry.',
DioExceptionType.cancel => 'The request was cancelled.',
_ when code == 401 || code == 403 =>
'The service denied access to this content.',
_ => 'The service request failed. Please retry.',
};
throw ApiException(message, statusCode: code);
} on FormatException {
throw const ApiException('The service returned an invalid response.');
}
}
}
}
+191
View File
@@ -0,0 +1,191 @@
import 'dart:collection';
import 'api_service.dart';
import 'content_policy.dart';
import 'models.dart';
/// Iran read-only discovery contracts, independent of account APIs.
class CatalogDiscovery {
CatalogDiscovery(this.api);
final ApiService api;
Future<dynamic> _legacy(String file, Map<String, dynamic> query) =>
api.get('${api.hostRoot}/api-user/$file', query: query);
Future<CatalogPage> suggestions({int page = 1}) async =>
_preservePage(await api.bestRandom(page: page), page);
Future<CatalogPage> search(
String query, {
int page = 1,
bool english = false,
DiscoverySearchFilter filter = const DiscoverySearchFilter(),
}) async {
final result = _preservePage(
await api.search(query, page: page, english: english),
page,
);
return CatalogPage(result.where(filter.accepts), nextPage: result.nextPage);
}
CatalogPage _preservePage(List<TitleRecord> rows, int page) => CatalogPage(
rows,
nextPage: rows is CatalogPage
? rows.nextPage
: rows.isEmpty
? null
: page + 1,
);
Future<DiscoveryObjectPage> directory(
DiscoveryDirectory kind, {
int page = 1,
}) async {
final themes = kind == DiscoveryDirectory.themes;
final raw = discoveryRows(
await _legacy(themes ? 'filmahang/star.php' : 'newapi/like.php', {
'action': themes ? 'mazmon' : 'colection',
'pageno': page,
}),
);
return DiscoveryObjectPage(
raw.map((row) => {...row, 'title': row['title'] ?? row['mazmon'] ?? ''}),
nextPage: themes || raw.isEmpty ? null : page + 1,
);
}
/// These endpoints return poster identifiers, not playable poster objects.
Future<CatalogPage> sharedTitles({int page = 1, bool newest = false}) async {
final rows = discoveryRows(
await _legacy('filmahang/star.php', {
'action': newest ? 'new' : 'share',
'pageno': page,
}),
);
final titles = <TitleRecord>[];
final ids = <int>{};
// Sequential hydration bounds server load on the account-free public API.
for (final row in rows) {
final id = int.tryParse('${row['id']}') ?? 0;
if (id > 0 && ids.add(id)) titles.add(await api.poster(id));
}
return CatalogPage(titles, nextPage: rows.isEmpty ? null : page + 1);
}
}
enum DiscoveryDirectory { themes, franchises }
class DiscoveryObjectPage extends ListBase<Map<String, dynamic>> {
DiscoveryObjectPage(
Iterable<Map<String, dynamic>> rows, {
required this.nextPage,
}) : _rows = List.unmodifiable(rows);
final List<Map<String, dynamic>> _rows;
final int? nextPage;
@override
int get length => _rows.length;
@override
set length(int value) => throw UnsupportedError('Read-only page');
@override
Map<String, dynamic> operator [](int index) => _rows[index];
@override
void operator []=(int index, Map<String, dynamic> value) =>
throw UnsupportedError('Read-only page');
}
/// The PHP search API has no evidenced advanced-filter parameters. Apply these
/// explicitly to each returned page, retaining its unfiltered continuation.
class DiscoverySearchFilter {
const DiscoverySearchFilter({
this.kind = 'mixed',
this.minimumYear = 0,
this.minimumRating = 0,
this.genre = 0,
this.country = 0,
});
final String kind;
final int minimumYear, genre, country;
final double minimumRating;
bool accepts(TitleRecord title) =>
(kind == 'mixed' ||
(kind == 'serie' ? title.isSeries : title.type == kind)) &&
(minimumYear == 0 || title.year >= minimumYear) &&
title.imdb >= minimumRating &&
(genre == 0 || title.genres.any((tag) => tag.id == genre)) &&
(country == 0 || title.countries.any((tag) => tag.id == country));
String get key => '$kind:$minimumYear:$minimumRating:$genre:$country';
}
/// A complete actor endpoint is unpaged. Only an empty/unavailable endpoint
/// switches to the observed actor-name search, whose raw pages remain paged.
class ActorFilmography {
ActorFilmography(this.discovery, {required this.id, required this.name});
final CatalogDiscovery discovery;
final int id;
final String name;
bool usingNameFallback = false;
Future<CatalogPage> load(int page) async {
if (page == 1 && id > 0 && !usingNameFallback) {
try {
final titles = await discovery.api.actorTitles(id);
if (titles.isNotEmpty) return CatalogPage(titles, nextPage: null);
} on ApiException catch (error) {
if (error.statusCode == 401 || error.statusCode == 403) rethrow;
}
}
usingNameFallback = true;
if (name.trim().isEmpty) return CatalogPage([], nextPage: null);
return discovery.search(name, page: page);
}
}
Map<String, dynamic> actorMetadata(Map<String, dynamic> row) {
final nested = row['actor'];
final person = nested is Map ? Map<String, dynamic>.from(nested) : row;
return {
...person,
'id': person['star_id'] ?? person['actor_id'] ?? person['id'] ?? 0,
'name':
person['star_name'] ??
person['actor_name'] ??
person['name'] ??
person['title'] ??
'',
'image': person['image'] ?? person['img'] ?? person['photo'] ?? '',
'role': row['role'] ?? person['role'] ?? '',
'bio': person['bio'] ?? person['biography'] ?? person['description'] ?? '',
};
}
MediaSource? currentHistorySource(
List<MediaSource> sources,
String previousUrl,
) {
final playable = sources.where((source) {
final uri = Uri.tryParse(source.url);
return !source.isTrailer &&
ContentPolicy.allowsUrl(source.url) &&
uri != null &&
{'https', 'http'}.contains(uri.scheme) &&
uri.host.isNotEmpty;
}).toList();
return playable.where((source) => source.url == previousUrl).firstOrNull ??
playable.firstOrNull;
}
List<Map<String, dynamic>> discoveryRows(dynamic raw) {
if (raw is Map) {
for (final key in ['posters', 'data', 'result', 'items']) {
if (raw[key] is List) return discoveryRows(raw[key]);
}
}
if (raw is! List) {
throw const ApiException(
'The service returned an unsupported response shape.',
);
}
return raw
.whereType<Map>()
.map((row) => Map<String, dynamic>.from(row))
.toList();
}
+305
View File
@@ -0,0 +1,305 @@
/// A presentation boundary for the independent, film/series-only client.
///
/// Catalog/media hosts are not blocked merely because the original service owns
/// them. The removed vendor destinations, announcements and navigation families
/// are rejected separately. This also applies to imported/previously saved data.
class ContentPolicy {
static const _removedKeys = {
'comment',
'comments',
'replies',
'channel',
'channels',
'livechannel',
'livechannels',
'live_channels',
'publicplaylist',
'publicplaylists',
'public_playlists',
'playlists',
'news',
'news_url',
'announcements',
'announcement',
'notifications',
'notification',
'update',
'updates',
'appupdate',
'app_update',
'app_version',
'latest_version',
'new_version',
'version_code',
'apk',
'apk_url',
'download_apk',
'support',
'support_url',
'supportlink',
'telegram',
'telegram_url',
'telegram_channel',
'telegramaccount',
'ads',
'advertisements',
'advertisement',
'admob',
'shop',
'shop_url',
'store_url',
'melodybox',
'norton',
};
static const _removedTypes = {
'channel',
'live',
'live_channel',
'news',
'announcement',
'advertisement',
'ad',
'app',
'apk',
'update',
'support',
'public_playlist',
};
static final _formatting = RegExp(
r'[\u064b-\u065f\u0670\u06d6-\u06ed\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]',
);
static final _urls = RegExp(
r'''(?:https?://|tg://|telegram://|www\.)[^\s<>"')]+''',
caseSensitive: false,
);
static final _handles = RegExp(r'(^|[\s>(])@[a-zA-Z][a-zA-Z0-9_]{3,}');
static String _decode(String value) {
var result = value;
for (var i = 0; i < 3; i++) {
final decoded = result.replaceAllMapped(RegExp(r'(?:%[0-9a-fA-F]{2})+'), (
match,
) {
try {
return Uri.decodeComponent(match.group(0)!);
} on FormatException {
return match.group(0)!;
} on ArgumentError {
return match.group(0)!;
}
});
if (decoded == result) break;
result = decoded;
}
return result
.replaceAllMapped(
RegExp(r'&#(x[0-9a-f]+|[0-9]+);', caseSensitive: false),
(match) {
final entity = match.group(1)!;
final code = int.tryParse(
entity.startsWith(RegExp('[xX]')) ? entity.substring(1) : entity,
radix: entity.startsWith(RegExp('[xX]')) ? 16 : 10,
);
return code != null && code >= 0 && code <= 0x10ffff
? String.fromCharCode(code)
: '';
},
)
.replaceAll('&amp;', '&')
.replaceAll('&nbsp;', ' ');
}
static String _normal(String value) =>
_decode(value)
.toLowerCase()
.replaceAll('ي', 'ی')
.replaceAll('ى', 'ی')
.replaceAll('ك', 'ک')
.replaceAll(_formatting, '');
static String _compact(String value) =>
_normal(value).replaceAll(RegExp(r'[\s_\-\u0640]+'), '');
static bool _brand(String value) {
final compact = _compact(value);
return compact.contains('ملودیباکس') ||
compact.contains('ملودیبکس') ||
compact.contains('melodybox') ||
compact.contains('melodibox') ||
compact.contains('نورتون') ||
compact.contains('نورتن') ||
compact.contains('norton');
}
static bool allowsText(String value) {
final normal = _normal(value);
final compact = _compact(value);
if (_brand(value) ||
normal.contains('تلگرام') ||
RegExp(r'\btelegram\b').hasMatch(normal) ||
_handles.hasMatch(normal)) {
return false;
}
if (RegExp(
r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)',
dotAll: true,
).hasMatch(normal) ||
RegExp(
r'(آپدیت|بروزرسانی|بهروز رسانی|بهروزرسانی).{0,20}(برنامه|اپلیکیشن|سیمبا)',
dotAll: true,
).hasMatch(normal) ||
RegExp(
r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)',
dotAll: true,
).hasMatch(normal)) {
return false;
}
if ((compact.contains('originalapp') ||
compact.contains('moviebox') ||
compact.contains('سیمبا')) &&
(compact.contains('support') ||
compact.contains('news') ||
compact.contains('پشتیبانی'))) {
return false;
}
return _urls
.allMatches(_decode(value))
.every((match) => allowsUrl(match.group(0)!));
}
static String sanitizeText(String value) =>
allowsText(value) ? value.replaceAll(_formatting, '').trim() : '';
static bool allowsUrl(String value) {
if (value.isEmpty) return true;
if (value != value.trim() || RegExp(r'[\x00-\x20\x7f]').hasMatch(value)) {
return false;
}
final decoded = _decode(value);
if (_brand(decoded) || decoded.contains('\\')) return false;
try {
final uri = Uri.parse(
decoded.startsWith('www.') ? 'https://$decoded' : decoded,
);
if (uri.userInfo.isNotEmpty ||
(uri.hasScheme &&
!{'http', 'https'}.contains(uri.scheme.toLowerCase()))) {
return false;
}
final host = uri.host.toLowerCase();
if ([
't.me',
'telegram.me',
'telegram.dog',
'telegram.org',
'telegra.ph',
].any((blocked) => host == blocked || host.endsWith('.$blocked'))) {
return false;
}
final path = _normal(uri.path);
if (RegExp(r'\.(apk|apks|xapk|aab|exe|msi|dmg|ipa)(/|$)')
.hasMatch(path)) {
return false;
}
if (RegExp(
r'(^|/)(original[-_]?app|moviebox|simba)/(support|news|updates?|downloads?)(/|$)',
).hasMatch(path)) {
return false;
}
if (RegExp(
r'(^|/)(app[-_]?updates?|download[-_]?apk|latest[-_]?version)(/|$)',
).hasMatch(path)) {
return false;
}
return true;
} on FormatException {
return false;
}
}
static bool allowsApiPath(Uri uri) {
final path = _normal(uri.path);
final actions = (uri.queryParametersAll['action'] ?? const <String>[]).map(
_normal,
);
if (RegExp(
r'(^|/)(comments?|replies|reply|channels?|public[-_]?playlists?|news|updates?|support)([-_][a-z0-9_-]+)?(\.php|/|$)',
).hasMatch(path)) {
return false;
}
return actions.every(
(action) =>
!{
'comment',
'comments',
'add-comment',
'reply',
'replies',
'pasokh',
'showlist',
'show-list_fav',
'public-playlists',
'playlist-items',
'app-update',
'app-news',
'telegram',
'support',
}.contains(action) &&
!RegExp(r'(^|[-_])(comments?|replies|reply|pasokh)([-_]|$)')
.hasMatch(action),
);
}
static bool _removedRecord(Map<String, dynamic> value) {
final type = _normal('${value['type'] ?? value['kind'] ?? ''}');
if (_removedTypes.contains(type)) return true;
for (final key in [
'title',
'name',
'description',
'note',
'message',
'text',
'content',
'body',
'summary',
'label',
]) {
final text = value[key];
if (text is String && !allowsText(text)) return true;
}
for (final key in [
'link',
'url',
'website',
'button_url',
'download_url',
'apk_url',
]) {
final url = value[key];
if (url is String && !allowsUrl(url)) return true;
}
return false;
}
static bool allowsTitleJson(Map<String, dynamic> json) =>
!_removedRecord(json);
/// Returns a fresh JSON tree; removed records/fields never become blank cards.
static dynamic sanitizeJson(dynamic value) {
if (value is Map) {
final map = Map<String, dynamic>.from(value);
if (_removedRecord(map)) return null;
final result = <String, dynamic>{};
for (final entry in map.entries) {
if (_removedKeys.contains(_normal(entry.key))) continue;
final item = sanitizeJson(entry.value);
if (item != null) result[entry.key] = item;
}
return result;
}
if (value is List) {
return value.map(sanitizeJson).where((v) => v != null).toList();
}
if (value is String) return allowsText(value) ? value : null;
return value;
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'api_service.dart';
import 'content_policy.dart';
class HomeBanner {
const HomeBanner._({
required this.titleEn,
required this.titleFa,
required this.subtitleEn,
required this.subtitleFa,
required this.target,
required this.isAdvertisement,
this.image,
});
final String titleEn, titleFa, subtitleEn, subtitleFa;
final Uri target;
final Uri? image;
final bool isAdvertisement;
String title(bool persian) => persian ? titleFa : titleEn;
String subtitle(bool persian) => persian ? subtitleFa : subtitleEn;
static final telegram = HomeBanner._(
titleEn: 'ReelBox updates',
titleFa: 'تازه‌های ریل‌باکس',
subtitleEn: 'Updates and announcements',
subtitleFa: 'به‌روزرسانی‌ها و اطلاعیه‌ها',
target: Uri.parse('https://t.me/ReelBoxApp'),
isAdvertisement: false,
);
}
/// Only configured advertisements are parsed. The official Telegram entry is
/// fixed in the client and can never be replaced or relabelled by server data.
List<HomeBanner> parseHomeBanners(dynamic raw) {
final result = <HomeBanner>[HomeBanner.telegram];
if (raw is! Map || raw['banners'] is! List) return result;
for (final row in (raw['banners'] as List).take(20)) {
if (result.length == 6) break;
if (row is! Map) continue;
final en = _label(row['title_en'], 80);
final fa = _label(row['title_fa'] ?? row['title_en'], 80);
final subtitleEn = _label(row['subtitle_en'] ?? '', 180);
final subtitleFa = _label(
row['subtitle_fa'] ?? row['subtitle_en'] ?? '',
180,
);
final target = _https(row['target_url']);
final imageValue = row['image_url'];
final image = imageValue == null || imageValue == ''
? null
: _https(imageValue);
if (en == null ||
en.isEmpty ||
fa == null ||
fa.isEmpty ||
subtitleEn == null ||
subtitleFa == null ||
target == null ||
(imageValue != null && imageValue != '' && image == null)) {
continue;
}
result.add(
HomeBanner._(
titleEn: en,
titleFa: fa,
subtitleEn: subtitleEn,
subtitleFa: subtitleFa,
target: target,
image: image,
isAdvertisement: true,
),
);
}
return List.unmodifiable(result);
}
String? _label(dynamic value, int limit) {
if (value is! String ||
value.length > limit ||
RegExp(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]').hasMatch(value) ||
!ContentPolicy.allowsText(value)) {
return null;
}
return value.trim();
}
Uri? _https(dynamic value) {
if (value is! String ||
value.length > 2048 ||
RegExp(r'[\x00-\x20\x7f\\]').hasMatch(value) ||
RegExp(
r'%(?![0-9a-fA-F]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)',
caseSensitive: false,
).hasMatch(value) ||
!ContentPolicy.allowsUrl(value)) {
return null;
}
try {
final uri = Uri.parse(value);
if (uri.scheme != 'https' ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.port != 443 ||
uri.host.length > 253 ||
!uri.host
.split('.')
.every(
(part) =>
RegExp(r'^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$')
.hasMatch(part),
)) {
return null;
}
return uri;
} on FormatException {
return null;
}
}
/// Uses only the existing trusted metadata transport, never a config URL.
class HomeBannerService {
const HomeBannerService(this.api);
final ApiService api;
Future<List<HomeBanner>> load() async {
try {
return parseHomeBanners(await api.get('banners/'));
} catch (_) {
// Announcements remain reachable when the metadata service is offline.
return [HomeBanner.telegram];
}
}
}
+202
View File
@@ -0,0 +1,202 @@
/// Public service metadata. Source visibility is not an authorization grant.
library;
import 'content_policy.dart';
Map<String, dynamic> _cleanJson(Map<String, dynamic> value) {
final clean = ContentPolicy.sanitizeJson(value);
return clean is Map ? Map<String, dynamic>.from(clean) : <String, dynamic>{};
}
String _text(dynamic value, [String fallback = '']) =>
value is String || value is num || value is bool ? '$value' : fallback;
int _integer(dynamic value) {
if (value is int) return value;
if (value is num) return value.isFinite ? value.toInt() : 0;
return int.tryParse(_text(value).trim()) ?? 0;
}
double _decimal(dynamic value) {
final parsed = value is num
? value.toDouble()
: double.tryParse(_text(value).trim());
return parsed != null && parsed.isFinite ? parsed : 0;
}
List<T> _records<T>(dynamic value, T Function(Map<String, dynamic>) parse) =>
value is List
? value
.whereType<Map>()
.map((item) => parse(Map<String, dynamic>.from(item)))
.toList()
: <T>[];
class Tag {
final int id;
final String title, image;
const Tag({this.id = 0, this.title = '', this.image = ''});
factory Tag.fromJson(Map<String, dynamic> json) => Tag(
id: _integer(json['id']),
title: ContentPolicy.sanitizeText(_text(json['title'])),
image: ContentPolicy.allowsUrl(_text(json['image']))
? _text(json['image'])
: '',
);
Map<String, dynamic> toJson() => {'id': id, 'title': title, 'image': image};
}
class MediaSource {
final int id;
final String quality, type, url;
const MediaSource({
this.id = 0,
this.quality = '',
this.type = '',
this.url = '',
});
factory MediaSource.fromJson(Map<String, dynamic> json) => MediaSource(
id: _integer(json['id']),
quality: ContentPolicy.sanitizeText(_text(json['quality'])),
type: _text(json['type']),
url: ContentPolicy.allowsUrl(_text(json['url'])) ? _text(json['url']) : '',
);
bool get isTrailer =>
RegExp(
r'تیزر|تریلر|trailer|teaser',
caseSensitive: false,
).hasMatch(quality) ||
type.toLowerCase() == 'trailer';
Map<String, dynamic> toJson() => {
'id': id,
'quality': quality,
'type': type,
'url': url,
};
}
class EpisodeRecord {
final int id;
final String title, description, duration;
final List<MediaSource> sources;
final Map<String, dynamic> raw;
const EpisodeRecord({
this.id = 0,
this.title = '',
this.description = '',
this.duration = '',
this.sources = const [],
this.raw = const {},
});
factory EpisodeRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return EpisodeRecord(
id: _integer(json['id']),
title: _text(json['title']),
description: _text(json['description']),
duration: _text(json['duration']),
sources: _records(json['sources'], MediaSource.fromJson),
raw: Map.unmodifiable(json),
);
}
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'title': title,
'description': description,
'duration': duration,
'sources': sources.map((source) => source.toJson()).toList(),
};
}
class SeasonRecord {
final int id;
final String title;
final List<EpisodeRecord> episodes;
final Map<String, dynamic> raw;
const SeasonRecord({
this.id = 0,
this.title = '',
this.episodes = const [],
this.raw = const {},
});
factory SeasonRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return SeasonRecord(
id: _integer(json['id']),
title: _text(json['title']),
episodes: _records(json['episodes'], EpisodeRecord.fromJson),
raw: Map.unmodifiable(json),
);
}
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'title': title,
'episodes': episodes.map((episode) => episode.toJson()).toList(),
};
}
class TitleRecord {
final int id, year;
final String type, title, description, duration, classification, image, cover;
final double imdb, rating;
final List<Tag> genres, countries;
final List<MediaSource> sources;
final Map<String, dynamic> raw;
const TitleRecord({
this.id = 0,
this.type = 'movie',
this.title = '',
this.description = '',
this.duration = '',
this.classification = '',
this.image = '',
this.cover = '',
this.year = 0,
this.imdb = 0,
this.rating = 0,
this.genres = const [],
this.countries = const [],
this.sources = const [],
this.raw = const {},
});
factory TitleRecord.fromJson(Map<String, dynamic> value) {
final json = _cleanJson(value);
return TitleRecord(
id: _integer(json['id'] ?? json['posted_id'] ?? json['movie_id']),
type: _text(json['type'] ?? json['kind'], 'movie'),
title: _text(json['title'] ?? json['name']),
description: _text(json['description']),
duration: _text(json['duration']),
classification: _text(json['classification']),
image: _text(json['image'] ?? json['img'] ?? json['poster']),
cover: _text(json['cover']),
year: _integer(json['year']),
imdb: _decimal(json['imdb']),
rating: _decimal(json['rating']),
genres: _records(json['genres'], Tag.fromJson),
countries: _records(json['country'] ?? json['countries'], Tag.fromJson),
sources: _records(json['sources'], MediaSource.fromJson),
raw: Map.unmodifiable(json),
);
}
bool get isSeries => type == 'serie' || type == 'series';
bool get isChannel => type == 'channel';
Map<String, dynamic> toJson() => {
...raw,
'id': id,
'type': type,
'title': title,
'description': description,
'duration': duration,
'classification': classification,
'image': image,
'cover': cover,
'year': year,
'imdb': imdb,
'rating': rating,
'genres': genres.map((tag) => tag.toJson()).toList(),
'country': countries.map((tag) => tag.toJson()).toList(),
'sources': sources.map((source) => source.toJson()).toList(),
};
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'app.dart';
import 'bootstrap.dart';
import 'data/api_service.dart';
import 'media/download_service.dart';
import 'state/app_state.dart';
export 'app.dart' show MyApp;
void main() {
WidgetsFlutterBinding.ensureInitialized();
LicenseRegistry.addLicense(() async* {
final notice = await rootBundle.loadString(
'assets/licenses/LIBVLC-NOTICE.txt',
);
final license = await rootBundle.loadString('assets/licenses/LGPL-2.1.txt');
yield LicenseEntryWithLineBreaks([
'LibVLC for Android',
], '$notice\n\n$license');
});
final state = AppState(api: ApiService());
runApp(
ReelboxBootstrap(
initialize: () async {
// Native engine failure is recoverable too, not outside the startup guard.
MediaKit.ensureInitialized();
await state.initialize(discover: false);
unawaited(
state.api.initialize().catchError((Object _) {
debugPrint(
'Server discovery unavailable; keeping the trusted API base.',
);
}),
);
if (!kIsWeb) {
DownloadService.instance.bindSettings(state);
unawaited(
DownloadService.instance.initialize().catchError((Object _) {
debugPrint('Background downloader initialization unavailable.');
}),
);
}
},
builder: (_) => MyApp(state: state),
),
);
}
+163
View File
@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import '../data/content_policy.dart';
import 'local_media_server.dart';
import 'segmented_download.dart';
import 'subtitle_webvtt.dart';
/// Explicit resource ownership, also injectable into the native-session tests.
class CastMediaLease {
CastMediaLease({required Map<String, dynamic> payload, this._onClose})
: _payload = Map<String, dynamic>.unmodifiable(payload);
final Map<String, dynamic> _payload;
final Future<void> Function()? _onClose;
Future<void>? _closing;
Map<String, dynamic> get payload => _payload;
Future<void> close() => _closing ??= Future<void>.sync(() async {
await _onClose?.call();
});
}
/// No filesystem or network side effects until an explicit Cast load.
class CastMediaShare {
static Future<CastMediaLease> prepare({
required Map<String, dynamic> payload,
String? localFile,
String? subtitleText,
InternetAddress? bindAddress,
String? advertisedHost,
}) async {
final result = Map<String, dynamic>.from(payload)..remove('subtitleUrl');
File? selected;
final extraFiles = <String, File>{};
if (localFile != null) {
if (!File(localFile).isAbsolute) {
throw ArgumentError('Select an absolute media file.');
}
selected = File(await File(localFile).resolveSymbolicLinks());
final stat = await selected.stat();
if (stat.type != FileSystemEntityType.file || stat.size <= 0) {
throw ArgumentError('Select an existing nonempty media file.');
}
if (RegExp(
r'\.(m3u8|mpd)$',
caseSensitive: false,
).hasMatch(selected.path)) {
final directory = selected.parent;
final index = File('${directory.path}/package.json');
if (!await index.exists() || await index.length() > 16 * 1024 * 1024) {
throw UnsupportedError('Select a complete indexed offline package.');
}
final id = directory.uri.pathSegments
.where((part) => part.isNotEmpty)
.last;
final downloads = SegmentedDownload(root: directory.parent);
final package = await downloads.load(id);
if (await File(package.path).resolveSymbolicLinks() != selected.path ||
!await downloads.validate(id)) {
throw StateError('Offline package is incomplete or damaged.');
}
for (final name in {
...package.manifests.keys,
...package.resources.map((resource) => resource.filename),
}) {
final file = File('${directory.path}/$name');
final canonical = File(await file.resolveSymbolicLinks());
if (canonical.parent.path != directory.path) {
throw StateError('Offline resource leaves the selected package.');
}
// The selected root is already registered under media.<extension>.
if (name != 'media.${selected.path.split('.').last.toLowerCase()}') {
extraFiles[name] = file;
}
}
} else if (!RegExp(
r'\.(mp4|m4v|mkv|webm|mov|avi|mp3|aac)$',
caseSensitive: false,
).hasMatch(selected.path)) {
throw UnsupportedError(
'This local media format requires an indexed offline package.',
);
}
result['contentType'] = _mime(selected.path);
} else {
final url = result['url'];
final uri = url is String ? Uri.tryParse(url) : null;
if (uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
!ContentPolicy.allowsUrl(url as String)) {
throw ArgumentError('A permitted HTTP(S) media URL is required.');
}
}
Directory? disposable;
LocalMediaServer? server;
try {
File? subtitles;
if (subtitleText != null) {
if (utf8.encode(subtitleText).length > 8 * 1024 * 1024) {
throw const FormatException('Subtitle exceeds 8 MB.');
}
final vtt = SubtitleWebVtt.convert(subtitleText);
disposable = await Directory.systemTemp.createTemp('reelbox-cast-');
subtitles = await File('${disposable.path}/selected.vtt')
.writeAsString(vtt, encoding: utf8, flush: true);
}
if (selected == null && subtitles == null) {
return CastMediaLease(payload: result);
}
final sharing = await LocalMediaServer.start(
file: selected ?? subtitles!,
subtitle: selected == null ? null : subtitles,
bindAddress: bindAddress,
advertisedHost: advertisedHost,
extraFiles: extraFiles,
);
server = sharing;
if (selected != null) result['url'] = sharing.mediaUri.toString();
if (subtitles != null) {
result['subtitleUrl'] =
(selected == null ? sharing.mediaUri : sharing.subtitleUri!)
.toString();
}
final owned = disposable;
return CastMediaLease(
payload: result,
onClose: () async {
try {
await sharing.close();
} finally {
if (owned != null && await owned.exists()) {
await owned.delete(recursive: true);
}
}
},
);
} catch (_) {
try {
await server?.close();
} finally {
if (disposable != null && await disposable.exists()) {
await disposable.delete(recursive: true);
}
}
rethrow;
}
}
static String _mime(String path) =>
switch (path.split('.').last.toLowerCase()) {
'webm' => 'video/webm',
'mkv' => 'video/x-matroska',
'mov' => 'video/quicktime',
'avi' => 'video/x-msvideo',
'mp3' => 'audio/mpeg',
'aac' => 'audio/aac',
'mpd' => 'application/dash+xml',
'm3u8' => 'application/vnd.apple.mpegurl',
_ => 'video/mp4',
};
}
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../state/app_state.dart';
import 'download_service.dart';
import 'downloads_page.dart';
typedef EnqueueDownload = Future<void> Function({
required TitleRecord title,
required MediaSource source,
EpisodeRecord? episode,
});
/// The primary action always queues inside Reelbox, never an external app.
class DownloadButton extends StatefulWidget {
const DownloadButton({
super.key,
required this.title,
required this.source,
this.episode,
this.enqueue,
this.openDownloads,
});
final TitleRecord title;
final MediaSource source;
final EpisodeRecord? episode;
final EnqueueDownload? enqueue;
final void Function(BuildContext context)? openDownloads;
@override
State<DownloadButton> createState() => _DownloadButtonState();
}
class _DownloadButtonState extends State<DownloadButton> {
bool _starting = false;
Future<void> _download() async {
if (_starting) return;
setState(() => _starting = true);
try {
final host = Uri.tryParse(widget.source.url)?.host.toLowerCase() ?? '';
if (RegExp(
'youtube|embed',
caseSensitive: false,
).hasMatch(widget.source.type) ||
host == 'youtu.be' ||
host == 'youtube.com' ||
host.endsWith('.youtube.com')) {
throw UnsupportedError(
tr(
context,
'Embedded sources cannot be downloaded. Choose a direct media source.',
'منابع جاسازی‌شده قابل دانلود نیستند. یک منبع رسانه مستقیم انتخاب کنید.',
),
);
}
await (widget.enqueue ?? DownloadService.instance.enqueue)(
title: widget.title,
source: widget.source,
episode: widget.episode,
);
if (!mounted) return;
if (widget.openDownloads case final open?) {
open(context);
} else {
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const DownloadsPage()));
}
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${tr(context, 'Could not start download', 'شروع دانلود ممکن نشد')}: ${downloadErrorMessage(error)}',
),
),
);
}
} finally {
if (mounted) setState(() => _starting = false);
}
}
@override
Widget build(BuildContext context) => FilledButton.icon(
onPressed: widget.source.url.isEmpty || _starting ? null : _download,
icon: _starting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download_outlined),
label: Text(tr(context, 'Download in Reelbox', 'دانلود در ریلباکس')),
);
}
+890
View File
@@ -0,0 +1,890 @@
import 'dart:convert';
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../data/models.dart';
import '../data/content_policy.dart';
import 'media_platform.dart';
import '../state/app_state.dart';
import 'media_helpers.dart';
import 'media_files_stub.dart'
if (dart.library.io) 'media_files_io.dart'
as files;
/// A snapshot of the persistent native task, not an optimistic success flag.
class DownloadEntry {
final Task task;
final String id,
title,
quality,
filename,
path,
url,
status,
error,
episodeTitle,
posterImage;
final int titleId;
final String titleKind;
final bool? downloadNotifications;
final int? episodeId;
final double progress, speed;
final int receivedBytes, totalBytes;
final TaskStatus taskStatus;
DownloadEntry._({
required this.task,
required this.id,
required this.title,
required this.quality,
required this.filename,
required this.path,
required this.url,
required this.status,
required this.error,
required this.episodeTitle,
required this.posterImage,
required this.titleId,
required this.titleKind,
required this.downloadNotifications,
required this.episodeId,
required this.progress,
required this.speed,
required this.receivedBytes,
required this.totalBytes,
required this.taskStatus,
});
factory DownloadEntry.fromTask(
Task task, {
TaskStatus taskStatus = TaskStatus.enqueued,
double progress = 0,
int totalBytes = 0,
int? receivedBytes,
double speed = 0,
String path = '',
String error = '',
}) {
Map<String, dynamic> meta = {};
try {
final decoded = jsonDecode(task.metaData);
if (decoded is Map) meta = Map<String, dynamic>.from(decoded);
} catch (_) {
/* Tasks from older installs can lack metadata. */
}
final fraction = taskStatus == TaskStatus.complete
? 1.0
: progress.isFinite
? progress.clamp(0.0, 1.0)
: 0.0;
final size = totalBytes > 0 ? totalBytes : 0;
return DownloadEntry._(
task: task,
id: task.taskId,
title: meta['title'] as String? ?? task.displayName,
quality: meta['quality'] as String? ?? '',
filename: task.filename,
path: path,
url: task.url,
status: taskStatus.name,
error: error,
episodeTitle: meta['episodeTitle'] as String? ?? '',
posterImage: meta['posterImage'] as String? ?? '',
titleId: meta['titleId'] as int? ?? 0,
titleKind: (meta['titleKind'] as String?)?.isNotEmpty == true
? meta['titleKind'] as String
: meta['episodeId'] == null
? 'movie'
: 'serie',
downloadNotifications:
meta['downloadNotifications'] as bool? ??
(task.notificationConfig != null ? true : null),
episodeId: meta['episodeId'] as int?,
progress: fraction,
speed: speed.isFinite && speed > 0 ? speed : 0,
receivedBytes: receivedBytes ?? (fraction * size).round(),
totalBytes: size,
taskStatus: taskStatus,
);
}
TitleRecord get restoredTitle => TitleRecord(
id: titleId,
title: title,
image: posterImage,
type: titleKind,
);
String get sourceType {
try {
return (jsonDecode(task.metaData) as Map)['sourceType'] as String? ?? '';
} catch (_) {
return '';
}
}
bool get isSegmented => task.group == DownloadService.packageGroup;
bool get visible =>
ContentPolicy.allowsTitleJson(restoredTitle.toJson()) &&
ContentPolicy.allowsText(episodeTitle) &&
ContentPolicy.allowsUrl(url);
bool get isComplete => taskStatus == TaskStatus.complete;
bool get canPause => taskStatus == TaskStatus.running;
bool get canResume => taskStatus == TaskStatus.paused;
bool get isActive => {
TaskStatus.running,
TaskStatus.enqueued,
TaskStatus.waitingToRetry,
}.contains(taskStatus);
}
/// Resolve a fresh durable snapshot, awaiting both storage and file paths.
Future<List<DownloadEntry>> readDownloadEntries(
Future<List<TaskRecord>> Function() load,
) async {
final records = await load();
return Future.wait(
records.map(
(record) async => DownloadEntry.fromTask(
record.task,
taskStatus: record.status,
progress: record.progress,
totalBytes: record.expectedFileSize,
path: await record.task.filePath(),
error: record.exception?.description ?? '',
),
),
);
}
TaskNotificationConfig? downloadNotificationConfig(bool enabled) => enabled
? TaskNotificationConfig(
running: const TaskNotification(
'Reelbox • downloading',
'{displayName}\n{progress} • {networkSpeed}',
),
complete: const TaskNotification(
'Reelbox • downloaded',
'{displayName}',
),
error: const TaskNotification(
'Reelbox • download failed',
'{displayName}',
),
paused: const TaskNotification('Reelbox • paused', '{displayName}'),
progressBar: true,
tapOpensFile: true,
)
: null;
// Shared by enqueue and restart recovery tests; metadata is the durable policy.
String downloadMetadata({
required TitleRecord title,
required MediaSource source,
required bool notifications,
EpisodeRecord? episode,
}) => jsonEncode({
'title': title.title,
'titleId': title.id,
'titleKind': title.type,
'downloadNotifications': notifications,
'quality': source.quality,
'sourceType': source.type,
'episodeId': episode?.id,
'episodeTitle': episode?.title ?? '',
'posterImage': title.image,
});
Future<bool> resumeDownloadTask(
DownloadEntry entry, {
required void Function(Task task, TaskNotificationConfig config) configure,
required Future<bool> Function(DownloadTask task) resume,
}) async {
if (entry.task is! DownloadTask) return false;
// Task.notificationConfig is deliberately omitted by plugin serialization.
// Unknown legacy policy stays silent rather than adopting today's setting.
final config = downloadNotificationConfig(
entry.downloadNotifications ?? false,
);
// The pinned plugin rejects an all-null notification configuration. Off-policy
// tasks have no templates registered (and this service has no group/default).
if (config != null) configure(entry.task, config);
return resume(entry.task as DownloadTask);
}
DownloadTask createDirectDownloadTask({
required String id,
required TitleRecord title,
required MediaSource source,
required AppSettings preferences,
EpisodeRecord? episode,
bool supportsParallel = false,
}) {
final task = DownloadTask(
taskId: id,
url: source.url,
filename: downloadFilename(
title: episode == null ? title.title : '${title.title} ${episode.title}',
identity: id,
url: source.url,
),
baseDirectory: BaseDirectory.applicationSupport,
directory: 'offline',
group: DownloadService.group,
notificationConfig: downloadNotificationConfig(
preferences.downloadNotifications,
),
updates: Updates.statusAndProgress,
requiresWiFi: preferences.wifiOnlyDownloads,
allowPause: true,
retries: 2,
displayName: episode == null
? title.title
: '${title.title} • ${episode.title}',
metaData: downloadMetadata(
title: title,
source: source,
episode: episode,
notifications: preferences.downloadNotifications,
),
);
// A normal GET is the compatibility path. Multipart workers require HEAD,
// a known length and advertised byte ranges; settings alone prove none of it.
if (preferences.parallelDownloads <= 1 || !supportsParallel) return task;
// 9.6.5 exposes real HTTP range chunk tasks, not concurrent whole-file copies.
return ParallelDownloadTask(
taskId: task.taskId,
url: task.url,
filename: task.filename,
chunks: preferences.parallelDownloads.clamp(1, 8),
baseDirectory: task.baseDirectory,
directory: task.directory,
group: task.group,
notificationConfig: task.notificationConfig,
updates: task.updates,
requiresWiFi: task.requiresWiFi,
allowPause: true,
retries: task.retries,
displayName: task.displayName,
metaData: task.metaData,
);
}
/// Rebuild a failed task without requiring a broken multipart server feature.
/// Identity, original network/notification policy and source headers survive.
DownloadTask createRetryDownloadTask(
DownloadEntry entry, {
required String id,
bool supportsParallel = false,
}) {
if (entry.task is! DownloadTask) {
throw UnsupportedError('This task is not downloadable.');
}
final original = entry.task is ParallelDownloadTask && !supportsParallel
? DownloadTask.fromJson({
...entry.task.toJson(),
'taskType': 'DownloadTask',
})
: entry.task as DownloadTask;
return original.copyWith(
taskId: id,
retriesRemaining: original.retries,
filename: downloadFilename(
title: entry.title,
identity: id,
url: entry.url,
),
notificationConfig: downloadNotificationConfig(
entry.downloadNotifications ?? false,
),
);
}
Future<void> retireExcludedDownload(
TaskRecord record, {
required Future<bool> Function(String) cancel,
}) async {
final entry = DownloadEntry.fromTask(record.task, taskStatus: record.status);
if (!entry.visible && (entry.isActive || entry.canResume)) {
// Cancel suppresses stale OS notifications; no files or records are deleted.
if (!await cancel(entry.id)) {
throw StateError('Could not stop an excluded legacy download.');
}
}
}
Future<void> replaceFailedDownload(
Task old,
DownloadTask replacement, {
required Future<bool> Function(DownloadTask) enqueue,
required Future<void> Function(String) deleteRecord,
}) async {
if (!await enqueue(replacement)) {
throw StateError(
'Android rejected the replacement download; the old record was retained.',
);
}
await deleteRecord(old.taskId);
}
/// Native WorkManager/UIDT jobs with the plugin's durable task database.
/// No database auto-cleanup: completed downloads remain in the offline library.
class DownloadService extends ChangeNotifier {
DownloadService._();
static final DownloadService instance = DownloadService._();
static const group = 'reelbox.video';
static const packageGroup = 'reelbox.offline.packages';
final Map<String, DownloadEntry> _entries = {};
final Set<String> _removing = {};
Future<void>? _initialization;
StreamSubscription<TaskRecord>? _databaseSubscription;
AppState? _state;
bool _ready = false;
bool get supported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
String? initializationError;
List<DownloadEntry> get items {
// Hide excluded legacy records without deleting the user's actual files.
final result = _entries.values.where((e) => e.visible).toList()
..sort((a, b) => b.task.creationTime.compareTo(a.task.creationTime));
return List.unmodifiable(result);
}
FileDownloader get _downloader => FileDownloader();
/// Optional live settings binding; otherwise read durable AppState settings
/// before each enqueue so the public enqueue signature needs no context.
void bindSettings(AppState state) {
if (identical(_state, state)) return;
_state?.removeListener(_settingsChanged);
_state = state;
state.addListener(_settingsChanged);
_settingsChanged();
}
void _settingsChanged() {
if (_state != null) {
configureNotifications(_state!.prefs.downloadNotifications);
}
if (_ready) {
unawaited(
_applyNetworkPolicy().catchError((Object e) {
initializationError = 'Could not apply download network settings.';
notifyListeners();
}),
);
}
}
Future<AppSettings> _preferences() async {
if (_state != null) return _state!.prefs;
final storage = await SharedPreferences.getInstance();
final raw = storage.getString(AppState.storageKey);
if (raw == null) return AppSettings();
final library = jsonDecode(raw) as Map<String, dynamic>;
return AppSettings.fromJson(
Map<String, dynamic>.from(library['settings'] as Map),
);
}
Future<void> _applyNetworkPolicy() async {
final prefs = await _preferences();
final ok = await _downloader.requireWiFi(
prefs.wifiOnlyDownloads
? RequireWiFi.forAllTasks
: RequireWiFi.forNoTasks,
);
if (!ok) {
throw StateError(
'The OS could not update the download network constraint.',
);
}
}
Future<void> initialize() {
if (!supported) {
initializationError =
'Background video downloads are available on Android only.';
return Future.value();
}
return _initialization ??= _initialize().catchError((
Object error,
StackTrace stack,
) {
_initialization = null;
initializationError =
'Native download initialization failed. Retry on Android.';
notifyListeners();
Error.throwWithStackTrace(error, stack);
});
}
Future<void> _initialize() async {
// Group callbacks avoid stealing the plugin's single-subscription stream.
_downloader.registerCallbacks(
group: group,
taskStatusCallback: _status,
taskProgressCallback: _progress,
);
_databaseSubscription ??= _downloader.database.updates.listen((record) {
if (record.task.group == group) unawaited(_acceptRecord(record));
});
for (final record in await _downloader.database.allRecords(group: group)) {
await retireExcludedDownload(
record,
cancel: _downloader.cancelTaskWithId,
);
}
await _downloader.start(autoCleanDatabase: false);
await _applyNetworkPolicy();
for (final record in await _downloader.database.allRecords(group: group)) {
await _acceptRecord(record);
}
for (final package in await files.recoverPackages()) {
_acceptPackage(package);
}
configureNotifications((await _preferences()).downloadNotifications);
_ready = true;
initializationError = null;
notifyListeners();
}
bool _notificationsEnabled = true;
TaskNotificationConfig? get notificationConfigForNewTask =>
downloadNotificationConfig(_notificationsEnabled);
/// Task-scoped configuration prevents stale group templates from leaking into
/// new downloads after the preference is disabled. Active OS jobs keep their
/// original notification policy until they finish.
void configureNotifications(bool enabled) {
_notificationsEnabled = enabled;
}
Future<void> refresh() async {
if (!supported) {
throw UnsupportedError('Download refresh requires Android.');
}
await initialize();
final before = Map<String, DownloadEntry>.of(_entries);
final snapshot = await readDownloadEntries(
() => _downloader.database.allRecords(group: group),
);
final ids = snapshot.map((entry) => entry.id).toSet();
for (final entry in snapshot) {
// A native callback that arrived during I/O is newer than this snapshot.
if (_removing.contains(entry.id) ||
!identical(_entries[entry.id], before[entry.id])) {
continue;
}
_entries[entry.id] = entry;
}
_entries.removeWhere(
(id, entry) =>
!entry.isSegmented &&
!ids.contains(id) &&
identical(entry, before[id]) &&
!entry.isActive,
);
notifyListeners();
}
Future<void> _acceptRecord(TaskRecord record) async {
if (_removing.contains(record.taskId)) return;
await retireExcludedDownload(record, cancel: _downloader.cancelTaskWithId);
final previous = _entries[record.taskId];
final path = await record.task.filePath();
if (_removing.contains(record.taskId)) return;
// A progress callback may have overtaken asynchronous path resolution.
final latest = _entries[record.taskId];
if (latest != null && !identical(latest, previous)) return;
_entries[record.taskId] = DownloadEntry.fromTask(
record.task,
taskStatus: record.status,
progress: record.progress,
totalBytes: record.expectedFileSize,
path: path,
speed: previous?.speed ?? 0,
error: record.exception?.description ?? '',
);
notifyListeners();
}
void _status(TaskStatusUpdate update) {
if (_removing.contains(update.task.taskId)) return;
final old = _entries[update.task.taskId];
_entries[update.task.taskId] = DownloadEntry.fromTask(
update.task,
taskStatus: update.status,
progress: old?.progress ?? 0,
totalBytes: old?.totalBytes ?? 0,
path: old?.path ?? '',
error: update.exception?.description ?? '',
);
notifyListeners();
}
void _progress(TaskProgressUpdate update) {
if (_removing.contains(update.task.taskId)) return;
final old = _entries[update.task.taskId];
_entries[update.task.taskId] = DownloadEntry.fromTask(
update.task,
taskStatus: old?.taskStatus ?? TaskStatus.running,
progress: update.progress >= 0 ? update.progress : old?.progress ?? 0,
totalBytes: update.expectedFileSize > 0
? update.expectedFileSize
: old?.totalBytes ?? 0,
path: old?.path ?? '',
speed: update.hasNetworkSpeed ? update.networkSpeed * 1000000 : 0,
error: old?.error ?? '',
);
notifyListeners();
}
Future<void> enqueue({
required TitleRecord title,
required MediaSource source,
EpisodeRecord? episode,
}) async {
if (!supported) {
throw UnsupportedError('Background downloads require the Android app.');
}
if (title.id <= 0 ||
title.title.trim().isEmpty ||
!ContentPolicy.allowsTitleJson(title.toJson()) ||
!ContentPolicy.allowsText(episode?.title ?? '') ||
!ContentPolicy.allowsUrl(source.url)) {
throw UnsupportedError(
'This title or destination is excluded from Reelbox.',
);
}
if (!isRemoteMedia(source.url) ||
RegExp(
r'drm|widevine|playready|fairplay|protected',
caseSensitive: false,
).hasMatch(source.type)) {
throw UnsupportedError(
'Only authorized, non-DRM HTTP(S) media can be downloaded.',
);
}
await initialize();
await _applyNetworkPolicy();
if (_entries.values.any(
(entry) =>
entry.url == source.url &&
(entry.isActive || entry.canResume || entry.isComplete),
)) {
throw StateError('This source is already in Downloads.');
}
final prefs = await _preferences();
configureNotifications(prefs.downloadNotifications);
if (prefs.downloadNotifications &&
await _downloader.permissions.status(PermissionType.notifications) !=
PermissionStatus.granted) {
await _downloader.permissions.request(PermissionType.notifications);
}
final id =
'${title.id}_${episode?.id ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
final task = createDirectDownloadTask(
id: id,
title: title,
source: source,
preferences: prefs,
episode: episode,
supportsParallel:
!_segmentedSource(source) &&
prefs.parallelDownloads > 1 &&
await files.supportsParallelDownload(source.url),
);
if (_segmentedSource(source)) {
if (prefs.wifiOnlyDownloads && !_downloader.isWiFi) {
throw StateError(
'Connect to Wi-Fi before preparing an offline package.',
);
}
final snapshot = await files.preparePackage(
id: id,
url: source.url,
type: source.type,
parallel: prefs.parallelDownloads,
metadata: {
'task': task.copyWith(group: packageGroup).toJson(),
'notifications': prefs.downloadNotifications,
},
);
_acceptPackage(snapshot);
_startPackage(id);
return;
}
final enqueued = await _downloader.enqueue(task);
if (!enqueued) {
throw StateError('Android rejected the background download job.');
}
// Read back the exact persistent task instead of calling queued success complete.
final record = await _downloader.database.recordForId(task.taskId);
if (record != null) {
await _acceptRecord(record);
} else {
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(task));
notifyListeners();
}
}
static bool _segmentedSource(MediaSource source) =>
isSegmentedMediaSource(source);
final _packageRuns = <String>{};
void _acceptPackage(Map<String, dynamic> snapshot) {
final metadata = Map<String, dynamic>.from(snapshot['metadata'] as Map);
final original = Task.createFromJson(
Map<String, dynamic>.from(metadata['task'] as Map),
);
final task = original.copyWith(filename: snapshot['manifest'] as String);
if (_removing.contains(task.taskId)) return;
final status = TaskStatus.values.firstWhere(
(s) => s.name == snapshot['status'],
orElse: () => TaskStatus.failed,
);
final bytes = (snapshot['receivedBytes'] as num?)?.toInt() ?? 0;
_entries[task.taskId] = DownloadEntry.fromTask(
task,
taskStatus: status,
progress: (snapshot['progress'] as num?)?.toDouble() ?? 0,
receivedBytes: bytes,
totalBytes: status == TaskStatus.complete ? bytes : 0,
path: snapshot['path'] as String,
error: snapshot['error'] as String? ?? '',
);
notifyListeners();
}
void _startPackage(String id) {
if (!_packageRuns.add(id)) {
throw StateError('This package is already active.');
}
unawaited(
files
.runPackage(id, _acceptPackage)
.catchError((Object e) {
final previous = _entries[id];
if (previous != null && !_removing.contains(id)) {
_entries[id] = DownloadEntry.fromTask(
previous.task,
taskStatus: TaskStatus.failed,
path: previous.path,
progress: previous.progress,
receivedBytes: previous.receivedBytes,
error: 'Offline package could not start: $e',
);
notifyListeners();
}
})
.whenComplete(() => _packageRuns.remove(id)),
);
}
/// Retries preserve the original URL/type, identity and notification policy.
/// The old failed RECORD is removed only after successful new native enqueue;
/// legacy user files are not removed as a side effect of replacing a record.
Future<void> retry(DownloadEntry entry) async {
await initialize();
if (!entry.visible || entry.isActive || entry.canResume) {
throw StateError('This download cannot be retried now.');
}
if (entry.isSegmented) {
_startPackage(entry.id);
return;
}
if (entry.task is! DownloadTask) {
throw UnsupportedError('This task is not downloadable.');
}
final source = MediaSource(
url: entry.url,
quality: entry.quality,
type: entry.sourceType,
);
if (_segmentedSource(source)) {
await enqueue(
title: entry.restoredTitle,
source: source,
episode: entry.episodeId == null
? null
: EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle),
);
await _downloader.database.deleteRecordWithId(entry.id);
} else {
final id =
'${entry.titleId}_${entry.episodeId ?? 0}_${DateTime.now().microsecondsSinceEpoch}';
// A failed multipart attempt must not loop on the same server feature,
// even if HEAD incorrectly advertised ranges. Retry as one normal GET.
final next = createRetryDownloadTask(entry, id: id);
await replaceFailedDownload(
entry.task,
next,
enqueue: _downloader.enqueue,
deleteRecord: (id) async {
await _downloader.database.deleteRecordWithId(id);
if (await _downloader.database.recordForId(id) != null) {
throw StateError('Could not replace the old download record.');
}
},
);
final record = await _downloader.database.recordForId(id);
if (record != null) {
await _acceptRecord(record);
} else {
_entries.putIfAbsent(id, () => DownloadEntry.fromTask(next));
}
}
_entries.remove(entry.id);
notifyListeners();
}
Future<void> reveal(DownloadEntry entry) async {
final local = await offlinePath(entry);
final path = entry.isSegmented
? await files.exportPackage(entry.id)
: local;
if (await MediaPlatform.invoke<bool>('revealFile', {'path': path}) !=
true) {
throw StateError(
'No application could reveal or export this offline media.',
);
}
}
Future<void> externalDownloader(
DownloadEntry entry, {
String packageName = 'ask',
}) async {
if (!entry.visible || !isRemoteMedia(entry.url)) {
throw UnsupportedError('This media cannot be handed off.');
}
if (_segmentedSource(MediaSource(url: entry.url, type: entry.sourceType))) {
throw UnsupportedError(
'Export a complete offline package instead; external downloaders may save only the manifest.',
);
}
if (await MediaPlatform.invoke<bool>('openDownloader', {
'url': entry.url,
'filename': entry.filename,
'packageName': packageName,
}) !=
true) {
throw StateError('No external downloader could accept this file.');
}
}
Future<void> pause(DownloadEntry entry) async {
await initialize();
if (entry.isSegmented) {
await files.pausePackage(entry.id);
_acceptPackage(await files.loadPackage(entry.id));
return;
}
if (entry.task is! DownloadTask ||
!await _downloader.pause(entry.task as DownloadTask)) {
throw StateError(
'Pause is unavailable. This server may not support byte ranges.',
);
}
}
Future<void> resume(DownloadEntry entry) async {
await initialize();
if (!entry.visible) {
throw UnsupportedError('This legacy title is excluded from Reelbox.');
}
if (entry.isSegmented) {
_startPackage(entry.id);
return;
}
if (!await resumeDownloadTask(
entry,
configure: (task, config) => _downloader.configureNotificationForTask(
task,
running: config.running,
complete: config.complete,
error: config.error,
paused: config.paused,
progressBar: config.progressBar,
tapOpensFile: config.tapOpensFile,
),
resume: _downloader.resume,
)) {
throw StateError(
'Cannot resume this task; remove it and start a new download.',
);
}
}
Future<void> cancel(DownloadEntry entry) async {
await initialize();
if (entry.isSegmented) {
await files.pausePackage(entry.id, cancel: true);
_acceptPackage(await files.loadPackage(entry.id));
return;
}
if (!await _downloader.cancelTaskWithId(entry.id)) {
throw StateError('The OS could not cancel this download.');
}
}
Future<void> remove(DownloadEntry entry) async {
await initialize();
if (entry.isActive || entry.canResume) await cancel(entry);
_removing.add(entry.id);
try {
if (entry.isSegmented) {
await files.deletePackage(entry.id);
_entries.remove(entry.id);
notifyListeners();
return;
}
final path = await entry.task.filePath();
await files.delete(path);
await _downloader.database.deleteRecordWithId(entry.id);
if (await _downloader.database.recordForId(entry.id) != null) {
throw StateError('Could not remove download record.');
}
_entries.remove(entry.id);
notifyListeners();
} finally {
_removing.remove(entry.id);
}
}
Future<String> offlinePath(DownloadEntry entry) async {
if (!entry.visible) {
throw UnsupportedError('This legacy title is excluded from Reelbox.');
}
if (!entry.isComplete) throw StateError('The download is not complete.');
if (entry.isSegmented) {
if (!await files.validatePackage(entry.id)) {
throw StateError(
'Offline package files are missing or damaged. Retry to repair.',
);
}
return (await files.loadPackage(entry.id))['path'] as String;
}
final path = await entry.task.filePath();
if (!await files.exists(path) ||
entry.totalBytes > 0 && await files.size(path) != entry.totalBytes) {
throw StateError(
'The downloaded file is missing. Remove the entry and download again.',
);
}
return path;
}
Future<void> open(DownloadEntry entry) async {
final path = await offlinePath(entry);
if (!await _downloader.openFile(filePath: path, mimeType: 'video/*')) {
throw StateError(
'No installed application could open this video. Use Play offline in Reelbox.',
);
}
}
}
+498
View File
@@ -0,0 +1,498 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/models.dart';
import '../state/app_state.dart';
import 'download_service.dart';
import 'player_page.dart';
/// Preserve the actual failure instead of suggesting an unrelated source fix.
String downloadErrorMessage(Object error) => switch (error) {
PlatformException(:final message, :final code) =>
message?.trim().isNotEmpty == true ? message! : code,
StateError(:final message) => message.toString(),
UnsupportedError(:final message) => message ?? error.toString(),
_ => error.toString(),
};
/// Renders the service snapshot only; no estimated progress, speed or ETA.
class DownloadTransferDetails extends StatelessWidget {
const DownloadTransferDetails({super.key, required this.entry});
final DownloadEntry entry;
@override
Widget build(BuildContext context) {
final status = switch (entry.taskStatus) {
TaskStatus.enqueued =>
entry.task.requiresWiFi
? tr(context, 'Queued · Wi-Fi required', 'در صف · نیازمند وای‌فای')
: tr(context, 'Queued', 'در صف'),
TaskStatus.running => tr(context, 'Downloading', 'در حال دانلود'),
TaskStatus.waitingToRetry => tr(
context,
'Waiting to retry',
'در انتظار تلاش دوباره',
),
TaskStatus.paused => tr(context, 'Paused', 'متوقف‌شده'),
TaskStatus.complete => tr(context, 'Downloaded', 'دانلودشده'),
TaskStatus.failed => tr(context, 'Failed', 'ناموفق'),
TaskStatus.canceled => tr(context, 'Canceled', 'لغوشده'),
TaskStatus.notFound => tr(context, 'Source not found', 'منبع پیدا نشد'),
};
final measured =
entry.totalBytes > 0 || entry.progress > 0 || entry.isComplete;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(status),
const SizedBox(height: 10),
LinearProgressIndicator(
value: measured
? entry.progress
: entry.isActive
? null
: 0,
),
const SizedBox(height: 8),
Text(
[
if (measured) '${(entry.progress * 100).round()}%',
if (entry.receivedBytes > 0 || entry.totalBytes > 0)
'${_bytes(entry.receivedBytes)} / ${entry.totalBytes > 0 ? _bytes(entry.totalBytes) : '—'}',
if (entry.taskStatus == TaskStatus.running && entry.speed > 0)
'${_bytes(entry.speed.round())}/s',
].join(' · '),
textDirection: TextDirection.ltr,
style: Theme.of(context).textTheme.bodySmall,
),
if (entry.error.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
entry.error,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
}
/// Labeled primary operations; pending native acknowledgments disable repeats.
class DownloadControls extends StatefulWidget {
const DownloadControls({
super.key,
required this.entry,
required this.onPause,
required this.onResume,
required this.onRetry,
required this.onCancel,
});
final DownloadEntry entry;
final Future<void> Function() onPause, onResume, onRetry, onCancel;
@override
State<DownloadControls> createState() => _DownloadControlsState();
}
class _DownloadControlsState extends State<DownloadControls> {
bool _busy = false;
Future<void> _run(Future<void> Function() operation) async {
if (_busy) return;
setState(() => _busy = true);
try {
await operation();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(downloadErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) => Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (widget.entry.canPause)
FilledButton.tonalIcon(
onPressed: _busy ? null : () => _run(widget.onPause),
icon: const Icon(Icons.pause),
label: Text(tr(context, 'Pause', 'توقف')),
),
if (widget.entry.canResume)
FilledButton.tonalIcon(
onPressed: _busy ? null : () => _run(widget.onResume),
icon: const Icon(Icons.play_arrow),
label: Text(tr(context, 'Resume', 'ادامه')),
),
if (!widget.entry.isActive &&
!widget.entry.canResume &&
!widget.entry.isComplete)
FilledButton.tonalIcon(
onPressed: _busy ? null : () => _run(widget.onRetry),
icon: const Icon(Icons.refresh),
label: Text(tr(context, 'Retry', 'تلاش دوباره')),
),
if (widget.entry.isActive || widget.entry.canResume)
OutlinedButton.icon(
onPressed: _busy ? null : () => _run(widget.onCancel),
icon: const Icon(Icons.cancel_outlined),
label: Text(tr(context, 'Cancel', 'لغو')),
),
if (_busy)
const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
);
}
/// Optional handoff stays secondary to Reelbox's own queue and controls.
class ExternalDownloadMenu extends StatelessWidget {
const ExternalDownloadMenu({super.key, required this.onSelected});
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) => PopupMenuButton<String>(
tooltip: tr(context, 'More download options', 'گزینه‌های بیشتر دانلود'),
icon: const Icon(Icons.more_vert),
onSelected: onSelected,
itemBuilder: (_) => [
PopupMenuItem(
value: 'ask',
child: Text(
tr(context, 'Choose external downloader…', 'انتخاب دانلودر بیرونی…'),
),
),
PopupMenuItem(
value: 'ADM',
child: Text(
tr(context, 'External downloader: ADM', 'دانلودر بیرونی: ADM'),
),
),
PopupMenuItem(
value: 'IDM',
child: Text(
tr(context, 'External downloader: IDM', 'دانلودر بیرونی: IDM'),
),
),
],
);
}
String _bytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
class DownloadsPage extends StatefulWidget {
const DownloadsPage({super.key});
@override
State<DownloadsPage> createState() => _DownloadsPageState();
}
class _DownloadsPageState extends State<DownloadsPage> {
final service = DownloadService.instance;
Future<void>? startup;
@override
void didChangeDependencies() {
super.didChangeDependencies();
service.bindSettings(AppScope.read(context));
startup ??= service.initialize();
}
Future<void> action(Future<void> operation) async {
try {
await operation;
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(e.toString())));
}
}
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Downloads', 'دانلودها'))),
body: !service.supported
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
tr(
context,
'Background downloads and offline playback are available in the Android app.',
'دانلود پس‌زمینه و پخش آفلاین در برنامه اندروید در دسترس است.',
),
textAlign: TextAlign.center,
),
),
)
: FutureBuilder<void>(
future: startup,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: Text(
service.initializationError ??
'Download initialization failed.',
),
),
FilledButton(
onPressed: () => setState(() {
startup = service.initialize();
}),
child: Text(tr(context, 'Retry', 'تلاش دوباره')),
),
],
),
);
}
return AnimatedBuilder(
animation: service,
builder: (context, _) {
final items = service.items;
if (items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.download_for_offline_outlined,
size: 52,
),
const SizedBox(height: 18),
Text(
tr(
context,
'Your offline library starts here',
'کتابخانه آفلاین شما از اینجا شروع می‌شود',
),
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
tr(
context,
'Choose a quality on a movie or episode and tap Download.',
'در صفحه فیلم یا قسمت، کیفیت را انتخاب کنید و دانلود را بزنید.',
),
textAlign: TextAlign.center,
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: () => action(service.refresh()),
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: items.length,
itemBuilder: (_, i) {
final entry = items[i];
return Card(
margin: const EdgeInsets.only(bottom: 14),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.title,
style: Theme.of(context)
.textTheme
.titleMedium,
),
if (entry.episodeTitle.isNotEmpty)
Text(entry.episodeTitle),
const SizedBox(height: 6),
if (entry.quality.isNotEmpty)
Text(entry.quality),
if (entry.isSegmented)
Text(
tr(
context,
'Offline package · interrupted packaging resumes from verified segments after reopening. Export includes all media files.',
'بسته آفلاین · پس از باز کردن دوباره، از قطعه‌های تأییدشده ادامه می‌یابد. خروجی شامل همه فایل‌ها است.',
),
style: Theme.of(context)
.textTheme
.bodySmall,
),
const SizedBox(height: 10),
DownloadTransferDetails(entry: entry),
Wrap(
spacing: 8,
children: [
DownloadControls(
key: ValueKey('controls:${entry.id}'),
entry: entry,
onPause: () => service.pause(entry),
onResume: () => service.resume(entry),
onRetry: () => service.retry(entry),
onCancel: () => service.cancel(entry),
),
if (entry.isComplete) ...[
FilledButton.tonalIcon(
icon: const Icon(Icons.play_arrow),
label: Text(
tr(
context,
'Play offline',
'پخش آفلاین',
),
),
onPressed: () =>
action(playOffline(entry)),
),
IconButton(
tooltip: tr(
context,
'Open file externally',
'باز کردن فایل بیرونی',
),
icon: const Icon(Icons.open_in_new),
onPressed: () =>
action(service.open(entry)),
),
IconButton(
tooltip: tr(
context,
'Reveal / export offline media',
'نمایش / خروجی رسانه آفلاین',
),
icon: const Icon(Icons.ios_share),
onPressed: () =>
action(service.reveal(entry)),
),
],
if (!entry.isSegmented)
ExternalDownloadMenu(
onSelected: (package) => action(
service.externalDownloader(
entry,
packageName: package,
),
),
),
IconButton(
tooltip: tr(
context,
'Delete file and record',
'حذف فایل و سابقه',
),
icon: const Icon(Icons.delete_outline),
onPressed: () async {
final confirmed =
await showDialog<bool>(
context: context,
builder: (dialogContext) =>
AlertDialog(
title: Text(
tr(
context,
'Delete this download?',
'این دانلود حذف شود؟',
),
),
content: Text(entry.title),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(
dialogContext,
false,
),
child: Text(
tr(
context,
'Cancel',
'انصراف',
),
),
),
FilledButton(
onPressed: () =>
Navigator.pop(
dialogContext,
true,
),
child: Text(
tr(
context,
'Delete',
'حذف',
),
),
),
],
),
);
if (confirmed == true) {
await action(service.remove(entry));
}
},
),
],
),
],
),
),
);
},
),
);
},
);
},
),
);
Future<void> playOffline(DownloadEntry entry) async {
final path = await service.offlinePath(entry);
if (!mounted) return;
final title = entry.restoredTitle;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(
title: title,
source: MediaSource(
url: entry.url,
quality: entry.quality,
type: entry.sourceType,
),
localFile: path,
episode: entry.episodeId == null
? null
: EpisodeRecord(id: entry.episodeId!, title: entry.episodeTitle),
),
),
);
}
}
+308
View File
@@ -0,0 +1,308 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
/// Explicit, short-lived private-LAN media sharing. The random session URL is a
/// bearer capability; never log/persist it. Only caller-registered files exist
/// in this HTTP namespace. No filesystem path is derived from a request.
class LocalMediaServer {
LocalMediaServer._(
this._server,
this._host,
this._token,
this._files,
this._mediaName,
this._subtitleName,
) : _port = _server.port {
_server.listen((request) {
final operation = _handle(request);
_pending.add(operation);
unawaited(operation.whenComplete(() => _pending.remove(operation)));
}, onError: (Object _) {});
}
final HttpServer _server;
final String _host, _token, _mediaName;
final String? _subtitleName;
final int _port;
final Map<String, _RegisteredMedia> _files;
final Set<Future<void>> _pending = {};
Future<void>? _closing;
bool _closed = false;
static Future<LocalMediaServer> start({
required File file,
File? subtitle,
InternetAddress? bindAddress,
String? advertisedHost,
Map<String, File> extraFiles = const {},
}) async {
final address = bindAddress ?? await _discoverAddress();
final hostAddress = InternetAddress.tryParse(
advertisedHost ?? address.address,
);
if (hostAddress == null ||
(!hostAddress.isLoopback && !_privateV4(hostAddress))) {
throw ArgumentError(
'Choose a private LAN address for local media sharing.',
);
}
if (!address.isLoopback &&
!_privateV4(address) &&
address.address != '0.0.0.0') {
throw ArgumentError(
'Local media sharing cannot bind a public interface.',
);
}
final mediaName = 'media${_extension(file)}';
final subtitleName = subtitle == null
? null
: 'subtitle${_extension(subtitle)}';
final selected = <String, File>{mediaName: file};
if (subtitleName != null && subtitle != null) {
selected[subtitleName] = subtitle;
}
for (final entry in extraFiles.entries) {
if (!_safeName(entry.key) || selected.containsKey(entry.key)) {
throw ArgumentError('Invalid or reserved local resource name.');
}
selected[entry.key] = entry.value;
}
final registered = <String, _RegisteredMedia>{};
for (final entry in selected.entries) {
registered[entry.key] = await _RegisteredMedia.create(entry.value);
}
final random = Random.secure();
final token = base64Url
.encode(List.generate(32, (_) => random.nextInt(256)))
.replaceAll('=', '');
final server = await HttpServer.bind(address, 0, shared: false);
server.autoCompress = false;
return LocalMediaServer._(
server,
hostAddress.address,
token,
registered,
mediaName,
subtitleName,
);
}
static bool _safeName(String name) =>
name.isNotEmpty &&
name
.split('/')
.every(
(part) =>
part.isNotEmpty &&
!part.startsWith('.') &&
RegExp(r'^[a-zA-Z0-9_.-]+$').hasMatch(part),
);
Uri _uri(String name) => Uri(
scheme: 'http',
host: _host,
port: _port,
pathSegments: [_token, ...name.split('/')],
);
Uri get mediaUri => _uri(_mediaName);
Uri? get subtitleUri => _subtitleName == null ? null : _uri(_subtitleName);
Future<void> close() => _closing ??= _close();
Future<void> _close() async {
_closed = true;
await _server.close(force: true);
await Future.wait(_pending.toList());
}
Future<void> _handle(HttpRequest request) async {
final response = request.response;
try {
final parts = request.uri.pathSegments;
final name = parts.length > 1 ? parts.skip(1).join('/') : '';
final resource =
parts.isNotEmpty && parts.first == _token && _safeName(name)
? _files[name]
: null;
if (_closed || resource == null || !await resource.unchanged()) {
response.statusCode = HttpStatus.notFound;
response.contentLength = 0;
await response.close();
return;
}
response.headers.set('access-control-allow-origin', '*');
response.headers.set(
'access-control-allow-methods',
'GET, HEAD, OPTIONS',
);
response.headers.set('access-control-allow-headers', 'Range, If-Range');
response.headers.set(
'access-control-expose-headers',
'Content-Length, Content-Range, Accept-Ranges',
);
if (request.headers.value('access-control-request-private-network') ==
'true') {
response.headers.set('access-control-allow-private-network', 'true');
}
response.headers.set(HttpHeaders.cacheControlHeader, 'no-store');
if (request.method == 'OPTIONS') {
response.statusCode = HttpStatus.noContent;
response.contentLength = 0;
await response.close();
return;
}
if (request.method != 'GET' && request.method != 'HEAD') {
response.statusCode = HttpStatus.methodNotAllowed;
response.headers.set(HttpHeaders.allowHeader, 'GET, HEAD, OPTIONS');
response.contentLength = 0;
await response.close();
return;
}
final size = resource.stat.size;
response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes');
response.headers.set(
HttpHeaders.contentTypeHeader,
_contentType(resource.source),
);
response.headers.set(
HttpHeaders.lastModifiedHeader,
HttpDate.format(resource.stat.modified.toUtc()),
);
var start = 0, end = size - 1;
final range = request.headers.value(HttpHeaders.rangeHeader);
if (range != null) {
final match = RegExp(r'^bytes=(\d*)-(\d*)$').firstMatch(range.trim());
var valid =
match != null &&
size > 0 &&
(match.group(1)!.isNotEmpty || match.group(2)!.isNotEmpty);
if (valid) {
if (match.group(1)!.isEmpty) {
final suffix = int.tryParse(match.group(2)!);
valid = suffix != null && suffix > 0;
if (valid) start = max(0, size - suffix);
} else {
final first = int.tryParse(match.group(1)!);
final last = match.group(2)!.isEmpty
? size - 1
: int.tryParse(match.group(2)!);
valid =
first != null && last != null && first < size && last >= first;
if (valid) {
start = first;
end = min(last, size - 1);
}
}
}
if (!valid) {
response.statusCode = HttpStatus.requestedRangeNotSatisfiable;
response.headers.set(HttpHeaders.contentRangeHeader, 'bytes */$size');
response.contentLength = 0;
await response.close();
return;
}
response.statusCode = HttpStatus.partialContent;
response.headers.set(
HttpHeaders.contentRangeHeader,
'bytes $start-$end/$size',
);
}
response.contentLength = size == 0 ? 0 : end - start + 1;
if (request.method == 'GET' && size > 0) {
await response.addStream(
File(resource.canonicalPath).openRead(start, end + 1),
);
}
await response.close();
} catch (_) {
// Never expose filesystem paths or capability tokens to a disconnected peer.
try {
await response.close();
} catch (_) {
/* Socket already closed. */
}
}
}
static String _extension(File file) =>
RegExp(r'\.[A-Za-z0-9]{1,8}$')
.firstMatch(file.path)
?.group(0)
?.toLowerCase() ??
'';
static String _contentType(File file) => switch (_extension(file)) {
'.mp4' || '.m4v' => 'video/mp4',
'.mkv' => 'video/x-matroska',
'.webm' => 'video/webm',
'.m3u8' => 'application/vnd.apple.mpegurl',
'.mpd' => 'application/dash+xml',
'.ts' => 'video/mp2t',
'.m4s' => 'video/iso.segment',
'.aac' => 'audio/aac',
'.mp3' => 'audio/mpeg',
'.vtt' => 'text/vtt; charset=utf-8',
'.srt' => 'text/plain; charset=utf-8',
'.ttml' || '.xml' => 'application/ttml+xml',
_ => 'application/octet-stream',
};
static bool _privateV4(InternetAddress address) {
if (address.type != InternetAddressType.IPv4) return false;
final b = address.rawAddress;
return b[0] == 10 ||
(b[0] == 172 && b[1] >= 16 && b[1] <= 31) ||
(b[0] == 192 && b[1] == 168);
}
static Future<InternetAddress> _discoverAddress() async {
final interfaces = await NetworkInterface.list(
type: InternetAddressType.IPv4,
);
for (final interface in interfaces) {
if (!RegExp(
r'^(wlan|wifi|eth|en)',
caseSensitive: false,
).hasMatch(interface.name)) {
continue;
}
for (final address in interface.addresses) {
if (_privateV4(address)) return address;
}
}
throw StateError(
'Connect to a private Wi-Fi or Ethernet network to share offline media.',
);
}
}
class _RegisteredMedia {
const _RegisteredMedia(this.source, this.canonicalPath, this.stat);
final File source;
final String canonicalPath;
final FileStat stat;
static Future<_RegisteredMedia> create(File file) async {
final source = file.absolute;
try {
final path = await source.resolveSymbolicLinks();
final stat = await File(path).stat();
if (stat.type != FileSystemEntityType.file) {
throw ArgumentError('Select an existing media file.');
}
return _RegisteredMedia(source, path, stat);
} on FileSystemException {
throw ArgumentError('The selected media file is unavailable.');
}
}
Future<bool> unchanged() async {
try {
if (await source.resolveSymbolicLinks() != canonicalPath) return false;
final current = await File(canonicalPath).stat();
return current.type == FileSystemEntityType.file &&
current.size == stat.size &&
current.modified == stat.modified;
} on FileSystemException {
return false;
}
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:path_provider/path_provider.dart';
import 'segmented_download.dart';
import '../data/content_policy.dart';
import 'dart:convert';
/// The native multipart worker starts with HEAD, so only opt into it when
/// that exact capability is advertised. A failed/unsupported probe is not a
/// failed download: the caller schedules an ordinary streaming GET instead.
Future<bool> supportsParallelDownload(String url) async {
final uri = Uri.tryParse(url);
if (uri == null ||
!{'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty ||
!ContentPolicy.allowsUrl(url)) {
return false;
}
final client = HttpClient()..connectionTimeout = const Duration(seconds: 3);
try {
final request = await client
.openUrl('HEAD', uri)
.timeout(const Duration(seconds: 4));
request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity');
final response = await request.close().timeout(const Duration(seconds: 4));
return response.statusCode == HttpStatus.ok &&
response.contentLength > 0 &&
(response.headers[HttpHeaders.acceptRangesHeader] ?? const <String>[])
.expand((value) => value.toLowerCase().split(','))
.any((value) => value.trim() == 'bytes');
} catch (_) {
return false;
} finally {
client.close(force: true);
}
}
Future<bool> exists(String path) async =>
await File(path).exists() && await File(path).length() > 0;
Future<int> size(String path) => File(path).length();
Future<void> delete(String path) async {
final file = File(path);
if (await file.exists()) await file.delete();
}
const _segmentGroup = 'reelbox.offline.resources';
Future<SegmentedDownload>? _packageEngine;
final _nativeTasks = <String, Set<String>>{};
final _activePackages = <String>{};
Future<SegmentedDownload> _engine() => _packageEngine ??= () async {
final support = await getApplicationSupportDirectory();
return SegmentedDownload(
root: Directory('${support.path}/offline_packages'),
transfer: _nativeTransfer,
cancelTransfers: _cancelTransfers,
);
}();
Future<void> _cancelTransfers(String id) async {
final ids = _nativeTasks[id]?.toList() ?? <String>[];
if (ids.isNotEmpty && !await FileDownloader().cancelTasksWithIds(ids)) {
throw StateError('Android could not cancel an offline resource task.');
}
}
Future<void> _nativeTransfer(
OfflinePackage p,
OfflineResource r,
String destination,
) async {
final original = Task.createFromJson(
Map<String, dynamic>.from(p.metadata['task'] as Map),
);
final metadata = original.metaData;
final meta = Map<String, dynamic>.from(jsonDecode(metadata) as Map);
if (!ContentPolicy.allowsTitleJson({
'title': meta['title'],
'type': meta['titleKind'],
}) ||
!ContentPolicy.allowsText(meta['episodeTitle'] as String? ?? '') ||
!ContentPolicy.allowsUrl(r.url)) {
throw UnsupportedError(
'Excluded offline metadata cannot start a native job.',
);
}
final id = '${p.id}_${r.filename}_${DateTime.now().microsecondsSinceEpoch}';
final enabled = (p.metadata['notifications'] as bool?) ?? false;
final task = DownloadTask(
taskId: id,
url: r.url,
headers: {
'Accept-Encoding': 'identity',
if (r.offset != null)
'Range': 'bytes=${r.offset}-${r.offset! + r.length! - 1}',
},
// Per-run names keep orphan OS writers away from the resumed destination.
filename: '${File(destination).uri.pathSegments.last}.$id.part',
directory: File(destination).parent.path,
baseDirectory: BaseDirectory.root,
group: _segmentGroup,
requiresWiFi: original.requiresWiFi,
retries: 2,
allowPause: false,
updates: Updates.statusAndProgress,
displayName: original.displayName,
metaData: metadata,
notificationConfig: enabled
? TaskNotificationConfig(
running: const TaskNotification(
'Reelbox • saving offline resource',
'{displayName}\n{progress}',
),
error: const TaskNotification(
'Reelbox • offline resource failed',
'{displayName}',
),
progressBar: true,
tapOpensFile: false,
)
: null,
);
(_nativeTasks[p.id] ??= <String>{}).add(id);
try {
final update = await FileDownloader().download(task);
if (update.status != TaskStatus.complete) {
throw StateError('Native resource download ${update.status.name}.');
}
final code = update.responseStatusCode;
if (r.offset != null) {
final range = update.responseHeaders?['content-range'] ?? '';
if (code != 206 ||
!RegExp('^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$')
.hasMatch(range)) {
throw const FormatException(
'Server did not honor the resource byte range.',
);
}
} else if (code != 200) {
throw StateError('Native resource HTTP $code.');
}
final expected = int.tryParse(
update.responseHeaders?['content-length'] ?? '',
);
final downloaded = File(await task.filePath());
if (expected != null && expected != await downloaded.length()) {
throw const FormatException('Truncated native resource.');
}
await downloaded.rename(destination);
} finally {
_nativeTasks[p.id]?.remove(id);
await FileDownloader().database.deleteRecordWithId(id);
}
}
Map<String, dynamic> _snapshot(OfflinePackage p) => {
...p.toJson(),
'path': p.path,
'directory': p.directory,
'receivedBytes': p.receivedBytes,
'progress': p.progress,
};
Future<List<Map<String, dynamic>>> recoverPackages() async {
// Interrupted child jobs must not race a newly resumed writer to the same .part.
final downloader = FileDownloader();
final tasks = await downloader.allTasks(group: _segmentGroup);
final orphans = tasks
.where((t) => !_nativeTasks.values.any((ids) => ids.contains(t.taskId)))
.toList();
if (orphans.isNotEmpty &&
!await downloader.cancelTasksWithIds(
orphans.map((t) => t.taskId).toList(),
)) {
throw StateError(
'Cannot recover while Android still owns an old resource job.',
);
}
return (await (await _engine()).recover()).map(_snapshot).toList();
}
Future<Map<String, dynamic>> preparePackage({
required String id,
required String url,
required String type,
required Map<String, dynamic> metadata,
required int parallel,
}) async {
final engine = await _engine();
engine.parallel = parallel.clamp(1, 8);
return _snapshot(
await engine.prepare(id: id, url: url, type: type, metadata: metadata),
);
}
Future<void> runPackage(
String id,
void Function(Map<String, dynamic>) onProgress,
) async {
if (!_activePackages.add(id)) {
throw StateError('Offline package is already active.');
}
try {
await (await _engine()).run(
id,
onProgress: (p) => onProgress(_snapshot(p)),
);
} finally {
_activePackages.remove(id);
}
}
Future<void> pausePackage(String id, {bool cancel = false}) async =>
(await _engine()).stop(id, cancel: cancel);
Future<void> deletePackage(String id) async => (await _engine()).delete(id);
Future<String> exportPackage(String id) async => (await _engine()).export(id);
Future<bool> validatePackage(String id) async => (await _engine()).validate(id);
Future<Map<String, dynamic>> loadPackage(String id) async =>
_snapshot(await (await _engine()).load(id));
+26
View File
@@ -0,0 +1,26 @@
Future<bool> supportsParallelDownload(String url) async => false;
Future<bool> exists(String path) async => false;
Future<int> size(String path) async => 0;
Future<void> delete(String path) async =>
throw UnsupportedError('Native files are unavailable in the browser.');
Future<List<Map<String, dynamic>>> recoverPackages() async => [];
Future<Map<String, dynamic>> preparePackage({
required String id,
required String url,
required String type,
required Map<String, dynamic> metadata,
required int parallel,
}) async => throw UnsupportedError('Offline packages require the Android app.');
Future<void> runPackage(
String id,
void Function(Map<String, dynamic>) onProgress,
) async => throw UnsupportedError('Offline packages require the Android app.');
Future<void> pausePackage(String id, {bool cancel = false}) async =>
throw UnsupportedError('Offline packages require the Android app.');
Future<void> deletePackage(String id) async =>
throw UnsupportedError('Offline packages require the Android app.');
Future<String> exportPackage(String id) async =>
throw UnsupportedError('Offline packages require the Android app.');
Future<bool> validatePackage(String id) async => false;
Future<Map<String, dynamic>> loadPackage(String id) async =>
throw UnsupportedError('Offline packages require the Android app.');
+443
View File
@@ -0,0 +1,443 @@
import 'dart:convert';
import 'package:media_kit/media_kit.dart';
import '../data/models.dart';
import '../state/app_state.dart';
/// Orchestrates pending player work across route disposal/source changes.
class PlaybackLifecycle {
PlaybackLifecycle({this.isMounted});
final bool Function()? isMounted;
bool _disposed = false;
int _openGeneration = 0;
// Platform/window state is shared by every player route, not just a widget.
static Future<void>? _systemTail;
static PlaybackLifecycle? _systemOwner;
bool get ownsSystem => _active && identical(_systemOwner, this);
bool get _active => !_disposed && (isMounted?.call() ?? true);
static Future<T> _serialize<T>(Future<T> Function() action) {
final previous = _systemTail;
// Drop completed futures when idle, rather than keeping a previous player's
// scheduling zone alive. Work is still serialized across active routes.
final work = previous == null
? Future<T>.microtask(action)
: previous.then((_) => action());
late final Future<void> tail;
tail = work
.then<void>((_) {}, onError: (Object _, StackTrace _) {})
.whenComplete(() {
if (identical(_systemTail, tail)) _systemTail = null;
});
_systemTail = tail;
return work;
}
Future<bool> setup(List<Future<void> Function()> steps) {
// Capture ownership before even awaiting the system-work queue.
final activeAtStart = _active;
return _serialize(() async {
if (!activeAtStart || !_active) return false;
_systemOwner = this;
for (final step in steps) {
await step();
if (!_active) return false;
}
return true;
});
}
Future<bool> open({
required Future<void> Function() save,
required Future<void> Function(bool Function() current) dispatch,
}) async {
final generation = ++_openGeneration;
bool current() => _active && generation == _openGeneration;
if (!current()) return false;
await save();
if (!current()) return false;
await dispatch(current);
return current();
}
Future<void> dispose(Future<void> Function() restore) {
_disposed = true;
_openGeneration++;
// A previous route must never restore global state over a newer owner.
return _serialize(() async {
if (!identical(_systemOwner, this)) return;
await restore();
if (identical(_systemOwner, this)) _systemOwner = null;
});
}
}
Future<void> dispatchMediaOpen({
required Future<void> Function(Media media, {bool play}) open,
required String uri,
required Duration start,
required bool autoplay,
}) => open(Media(uri, start: start), play: autoplay);
/// Keep startup/failed-open zero state out of durable resume history.
class PlaybackCheckpoint {
Duration position = Duration.zero;
Duration duration = Duration.zero;
bool _opened = false;
bool get canPersist => _opened && position > Duration.zero;
void beginOpen() {
_opened = false;
position = Duration.zero;
duration = Duration.zero;
}
void didOpen() {
_opened = true;
}
}
EpisodeRecord? nextEpisode(List<EpisodeRecord> episodes, int? currentId) {
final index = episodes.indexWhere((episode) => episode.id == currentId);
return index >= 0 && index + 1 < episodes.length ? episodes[index + 1] : null;
}
String watchKey(int titleId, int? episodeId) =>
'$titleId:${episodeId ?? 'movie'}';
Duration resumePosition({
required Duration position,
required Duration duration,
int rewindSeconds = 0,
}) {
if (position < const Duration(seconds: 5)) return Duration.zero;
if (duration > Duration.zero &&
(position >= duration - const Duration(seconds: 15) ||
position.inMilliseconds / duration.inMilliseconds >= .95)) {
return Duration.zero;
}
return Duration(
milliseconds: (position.inMilliseconds - rewindSeconds.clamp(0, 120) * 1000)
.clamp(0, position.inMilliseconds),
);
}
/// Shared by enqueue, retry and external handoff: manifest aliases must never
/// fall through to a direct-file job, including extensionless MPD endpoints.
bool isSegmentedMediaSource(MediaSource source) {
final path = Uri.tryParse(source.url)?.path.toLowerCase() ?? '';
final type = source.type.split(';').first.trim().toLowerCase();
return path.endsWith('.m3u8') ||
path.endsWith('.mpd') ||
type == 'mpd' ||
RegExp(r'hls|dash|m3u8|mpegurl').hasMatch(type);
}
String? downloadUnsupportedReason(MediaSource source) {
if (!isRemoteMedia(source.url)) {
return 'Only public HTTP(S) files can be downloaded.';
}
if (isSegmentedMediaSource(source)) {
return 'Use offline HLS/DASH segment packaging instead of a direct video file.';
}
return null;
}
String downloadFilename({
required String title,
required String identity,
required String url,
}) {
var name = title
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1f]'), '_')
.replaceAll(RegExp(r'^\.+_?'), '')
.trim();
if (name.isEmpty) name = 'Video';
if (name.length > 90) name = name.substring(0, 90);
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
final ext =
RegExp(r'\.(mp4|mkv|webm|mov|avi|m4v|ts)$').firstMatch(path)?.group(1) ??
'mp4';
return '${name}_$identity.$ext';
}
bool isRemoteMedia(String url) {
final uri = Uri.tryParse(url);
return uri != null &&
(uri.scheme == 'https' || uri.scheme == 'http') &&
uri.host.isNotEmpty;
}
MediaSource? selectSource(
Iterable<MediaSource> sources, {
String preferredQuality = '',
}) {
final options = playableSources(sources);
if (options.isEmpty) return null;
return options.firstWhere(
(s) => s.quality == preferredQuality,
orElse: () => options.first,
);
}
List<MediaSource> playableSources(Iterable<MediaSource> sources) {
final seen = <String>{};
return sources
.where((s) => !s.isTrailer && isRemoteMedia(s.url) && seen.add(s.url))
.toList();
}
/// Strict Unicode first; legacy Persian/Arabic files commonly use Windows-1256.
String decodeSubtitle(List<int> bytes) {
if (bytes.length >= 2 &&
((bytes[0] == 255 && bytes[1] == 254) ||
(bytes[0] == 254 && bytes[1] == 255))) {
final little = bytes[0] == 255;
if (bytes.length.isOdd) {
throw const FormatException('Truncated UTF-16 subtitle');
}
return String.fromCharCodes([
for (var i = 2; i + 1 < bytes.length; i += 2)
little ? bytes[i] | bytes[i + 1] << 8 : bytes[i] << 8 | bytes[i + 1],
]);
}
try {
return utf8.decode(bytes).replaceFirst(RegExp(r'^\uFEFF'), '');
} on FormatException {
return String.fromCharCodes(
bytes.map((b) => b < 128 ? b : _windows1256[b - 128]),
);
}
}
// Unicode mapping of the Windows-1256 high-byte range (not Latin-1).
const _windows1256 = [
8364,
1662,
8218,
402,
8222,
8230,
8224,
8225,
710,
8240,
1657,
8249,
338,
1670,
1688,
1672,
1711,
8216,
8217,
8220,
8221,
8226,
8211,
8212,
1705,
8482,
1681,
8250,
339,
8204,
8205,
1722,
160,
1548,
162,
163,
164,
165,
166,
167,
168,
169,
1726,
171,
172,
173,
174,
175,
176,
177,
178,
179,
180,
181,
182,
183,
184,
185,
1563,
187,
188,
189,
190,
1567,
1729,
1569,
1570,
1571,
1572,
1573,
1574,
1575,
1576,
1577,
1578,
1579,
1580,
1581,
1582,
1583,
1584,
1585,
1586,
1587,
1588,
1589,
1590,
215,
1591,
1592,
1593,
1594,
1600,
1601,
1602,
1603,
224,
1604,
226,
1605,
1606,
1607,
1608,
231,
232,
233,
234,
235,
1609,
1610,
238,
239,
1611,
1612,
1613,
1614,
244,
1615,
1616,
247,
1617,
249,
1618,
251,
252,
8206,
8207,
1746,
];
class SubtitleVariant {
const SubtitleVariant(this.language, this.label, this.url);
final String language, label, url;
}
/// Accept flat lists as well as language groups containing release variants.
Map<String, List<SubtitleVariant>> subtitleGroups(
List<Map<String, dynamic>> rows,
) {
final result = <String, List<SubtitleVariant>>{};
final seen = <String>{};
String text(dynamic value) => value is Map
? '${value['name'] ?? value['title'] ?? value['code'] ?? ''}'
: value is String
? value
: '';
void visit(Map item, String inherited) {
final lang = text(item['language']).trim();
final language = lang.isEmpty ? inherited : lang;
final url = text(item['url'] ?? item['link']);
if (isRemoteMedia(url) && seen.add(url)) {
final label = text(item['title'] ?? item['name'] ?? item['quality']);
(result[language] ??= []).add(
SubtitleVariant(language, label.isEmpty ? 'Subtitle' : label, url),
);
}
for (final key in ['subtitles', 'variants', 'files', 'releases', 'items']) {
final children = item[key];
if (children is List) {
for (final child in children.whereType<Map>()) {
visit(child, language);
}
}
}
}
for (final item in rows) {
visit(item, 'Other');
}
return result;
}
Future<void> applyPlayerOptions(
AppSettings prefs, {
required Future<void> Function(double) rate,
required Future<void> Function(bool) audioOnly,
required Future<void> Function(double) volume,
bool Function()? current,
}) async {
if (current != null && !current()) return;
await rate(prefs.playbackSpeed);
if (current != null && !current()) return;
await audioOnly(prefs.audioOnly);
if (current != null && !current()) return;
if (prefs.rememberVolume) await volume(prefs.playerVolume);
}
AppSettings resetSubtitleSettings(AppSettings prefs) {
final defaults = AppSettings();
return prefs.copyWith(
subtitleEnabled: defaults.subtitleEnabled,
subtitleFontFamily: defaults.subtitleFontFamily,
subtitleFontSize: defaults.subtitleFontSize,
subtitleColor: defaults.subtitleColor,
subtitleBackground: defaults.subtitleBackground,
subtitleDelay: defaults.subtitleDelay,
);
}
AppSettings resetPlayerSettings(AppSettings prefs) {
final d = AppSettings();
return resetSubtitleSettings(prefs).copyWith(
playbackSpeed: d.playbackSpeed,
audioDelay: d.audioDelay,
videoFit: d.videoFit,
audioOnly: d.audioOnly,
playerVolume: d.playerVolume,
rememberVolume: d.rememberVolume,
backgroundAudio: d.backgroundAudio,
closeOnCompletion: d.closeOnCompletion,
resumeRewindSeconds: d.resumeRewindSeconds,
skipSeconds: d.skipSeconds,
resumePlayback: d.resumePlayback,
autoplay: d.autoplay,
autoNext: d.autoNext,
orientation: d.orientation,
externalPlayer: d.externalPlayer,
externalPlayerPackage: d.externalPlayerPackage,
);
}
String mediaContentType(String url) {
final path = Uri.tryParse(url)?.path.toLowerCase() ?? '';
if (path.endsWith('.m3u8')) return 'application/x-mpegURL';
if (path.endsWith('.mpd')) return 'application/dash+xml';
if (path.endsWith('.webm')) return 'video/webm';
if (path.endsWith('.mkv')) return 'video/x-matroska';
return 'video/mp4';
}
+397
View File
@@ -0,0 +1,397 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Captures Android controls on entry and restores the activity's brightness
/// override on exit. System volume is intentionally not restored.
class PlayerSystemControls {
double brightness = .5, volume = .5;
// The activity baseline belongs to the shared player-control session. A
// replacement route must not adopt the previous route's override as baseline.
static double? _originalBrightness;
Future<void> capture() async {
if (!MediaPlatform.supported) return;
final original = await MediaPlatform.invoke<double>('getBrightness');
final effective = original != null && original >= 0
? original
: await MediaPlatform.invoke<double>('getEffectiveBrightness');
final currentVolume = await MediaPlatform.invoke<double>('getVolume');
_originalBrightness ??= original ?? -1;
if (effective != null && effective.isFinite) {
brightness = effective.clamp(0, 1);
}
if (currentVolume != null && currentVolume.isFinite) {
volume = currentVolume.clamp(0, 1);
}
}
Future<void> restore() async {
final original = _originalBrightness;
_originalBrightness = null;
if (original != null && MediaPlatform.supported) {
await MediaPlatform.invoke<void>('setBrightness', {'value': original});
}
}
}
/// Android channel implemented by MainActivity; no synthetic platform success.
class MediaPlatform {
static const channel = MethodChannel('org.reelbox/media');
static bool get supported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
static Future<T?> invoke<T>(
String method, [
Map<String, dynamic>? args,
]) async {
if (!supported) {
throw UnsupportedError('This control is available on Android only.');
}
return channel.invokeMethod<T>(method, args);
}
static Future<bool> enterPip() async =>
await invoke<bool>('enterPip') ?? false;
static Future<void> setActive(bool active) async {
if (supported) await invoke<void>('setPlayerActive', {'active': active});
}
static Future<void> openExternal(
String url, {
String packageName = 'ask',
String? title,
String? subtitleUrl,
Duration? position,
}) async {
final ok = await invoke<bool>('openExternal', {
'url': url,
'packageName': packageName,
'title': ?title,
'subtitleUrl': ?subtitleUrl,
if (position != null) 'positionMs': position.inMilliseconds,
});
if (ok != true) {
throw StateError(
'No installed external video player can open this source.',
);
}
}
}
/// Single-owner notification/Cast bridge. The local media_kit player remains the
/// decoder; the Android foreground service never opens a second audio stream.
class PlayerNativeSession {
PlayerNativeSession({
required this.invoke,
required this.local,
required this.media,
required this.isPlaying,
required this.isCurrent,
this.changed,
this.failure,
this.prepareMedia,
this.revokeMedia,
this._castRequested = false,
});
Future<dynamic> Function(String, Map<String, dynamic>?) invoke;
final Future<void> Function(String, Duration?) local;
final Map<String, dynamic> Function() media;
final bool Function() isPlaying, isCurrent;
final void Function()? changed;
final void Function(String)? failure;
final Future<Map<String, dynamic>> Function()? prepareMedia;
final Future<void> Function()? revokeMedia;
bool _castRequested;
static PlayerNativeSession? _owner;
bool _released = false,
_loading = false,
_background = false,
_stopped = false;
bool _castAttempted = false;
Future<void>? _loadTail;
bool? _loadPlayState;
int _castRevision = 0;
int? _disconnectedRevision;
bool casting = false, connected = false, playing = false, inPip = false;
Duration position = Duration.zero, duration = Duration.zero;
String deviceName = '';
bool get active => !_released && identical(_owner, this) && isCurrent();
void claim() {
_owner = this;
MediaPlatform.channel.setMethodCallHandler(handle);
}
void release() {
_released = true;
_disconnectedRevision = null;
_castRevision++;
unawaited(_revoke().catchError((Object _) {}));
if (identical(_owner, this)) {
_owner = null;
MediaPlatform.channel.setMethodCallHandler(null);
}
}
Future<void> handle(MethodCall call) async {
if (!active) return;
if (call.method == 'pipChanged') {
inPip = call.arguments == true;
changed?.call();
return;
}
if (call.arguments is! Map) return;
final args = Map<String, dynamic>.from(call.arguments as Map);
if (call.method == 'mediaAction') {
final action = args['action'];
if (action is String &&
const [
'play',
'pause',
'seek',
'stop',
'next',
'previous',
].contains(action)) {
final ms = args['positionMs'];
if (action == 'seek' && (ms is! num || !ms.isFinite || ms < 0)) return;
await command(
action,
position: ms is num ? Duration(milliseconds: ms.toInt()) : null,
);
}
} else if (call.method == 'castChanged') {
final wasConnected = connected;
connected = args['connected'] == true;
deviceName = args['deviceName'] is String
? args['deviceName'] as String
: deviceName;
if (!connected) {
_castAttempted = false;
if (wasConnected) _disconnectedRevision = _castRevision;
_castRevision++;
await _revoke();
if (casting) {
final resume = playing;
casting = false;
await local('seek', position);
if (!active) return;
await local(resume ? 'play' : 'pause', null);
}
} else if (_castRequested && !casting && !_loading && !_castAttempted) {
await loadCast();
} else if (casting) {
playing = args['playing'] == true;
final p = args['positionMs'], d = args['durationMs'];
if (p is num && p.isFinite && p >= 0) {
position = Duration(milliseconds: p.toInt());
}
if (d is num && d.isFinite && d >= 0) {
duration = Duration(milliseconds: d.toInt());
}
}
if (active) changed?.call();
}
}
/// Cancel snapshots synchronously, before a source/subtitle change awaits.
/// This does not grant sharing consent or enqueue a replacement transfer.
Future<void> invalidateCastMedia() {
_castRevision++;
_disconnectedRevision = null;
return _revoke();
}
Future<void> loadCast() {
if (!active) return Future<void>.value();
_disconnectedRevision = null;
_castAttempted = true;
_castRequested = true;
final revision = ++_castRevision;
_loadPlayState ??= isPlaying();
final previous = _loadTail;
final work = previous == null
? Future<void>.microtask(() => _runCastLoad(revision))
: previous.then((_) => _runCastLoad(revision));
late final Future<void> tail;
tail = work.catchError((Object _) {}).whenComplete(() {
if (identical(_loadTail, tail)) {
_loadTail = null;
_loadPlayState = null;
}
});
_loadTail = tail;
return work;
}
Future<void> _runCastLoad(int revision) async {
if (!active || revision != _castRevision || !connected) return;
_loading = true;
final wasPlaying = _loadPlayState ?? isPlaying();
var paused = false;
try {
final payload = Map<String, dynamic>.from(
prepareMedia == null ? media() : await prepareMedia!(),
);
if (!active || revision != _castRevision) {
await _revoke();
return;
}
await local('pause', null);
paused = true;
if (!active || revision != _castRevision) {
await _revoke();
await _restoreDisconnectedLoad(revision);
return;
}
final ok = await invoke('castLoad', payload);
if (!active) return;
if (revision != _castRevision) {
if (_stopped) await invoke('castCommand', {'action': 'stop'});
await _revoke();
await _restoreDisconnectedLoad(revision);
return;
}
if (ok != true) throw StateError('Cast load rejected');
casting = true;
playing = _loadPlayState ?? wasPlaying;
position = Duration(
milliseconds: (payload['positionMs'] as num?)?.toInt() ?? 0,
);
if (!playing) await invoke('castCommand', {'action': 'pause'});
// A remote session must not also advertise a local background session.
if (_background) {
await invoke('backgroundPlaybackStop', null);
_background = false;
}
} catch (error) {
await _revoke();
if (!active || revision != _castRevision) {
if (paused) await _restoreDisconnectedLoad(revision);
return;
}
casting = false;
if (paused) {
await local((_loadPlayState ?? wasPlaying) ? 'play' : 'pause', null);
}
if (active && revision == _castRevision) {
failure?.call(
error is FormatException || error is UnsupportedError
? 'This media or subtitle format cannot be shared with the Cast device. Local playback restored.'
: 'Could not load this video on the Cast device. Local playback restored.',
);
}
} finally {
_loading = false;
if (active) changed?.call();
}
}
Future<void> _revoke() async {
await revokeMedia?.call();
}
Future<void> _restoreDisconnectedLoad(int revision) async {
// Only disconnect may restore an interrupted transfer. A newer media load,
// stop or owner must never be undone by the old decoder pause completing.
if (active &&
!connected &&
!_stopped &&
_disconnectedRevision == revision) {
await local((_loadPlayState ?? isPlaying()) ? 'play' : 'pause', null);
}
}
Future<void> command(
String action, {
Duration? position,
double? value,
}) async {
if (!active) return;
if (_loadTail != null && (action == 'play' || action == 'pause')) {
_loadPlayState = action == 'play';
}
if (action == 'play') _stopped = false;
if (action == 'stop') {
_stopped = true;
_disconnectedRevision = null;
_castRequested = false;
_castRevision++;
await stopBackground();
if (!active) return;
try {
if (casting) await invoke('castCommand', {'action': 'stop'});
await local('stop', null);
} finally {
casting = false;
await _revoke();
if (active) changed?.call();
}
return;
}
if (casting &&
const ['play', 'pause', 'seek', 'stop', 'volume'].contains(action)) {
await invoke('castCommand', {
'action': action,
if (position != null) 'positionMs': position.inMilliseconds,
'value': ?value,
});
} else {
await local(action, position);
}
}
Future<bool> picker() async {
if (!active) return false;
_castRequested = true;
return await invoke('castShowPicker', null) == true;
}
Future<void> disconnect() async {
if (!active) return;
_castRequested = false;
await invoke('castDisconnect', null);
if (active) {
await handle(const MethodCall('castChanged', {'connected': false}));
}
}
Future<void> refreshCast() async {
if (!active) return;
final state = await invoke('castGetState', null);
if (active && state is Map) await handle(MethodCall('castChanged', state));
}
Future<void> background({
required bool enabled,
required bool playing,
required Duration position,
required Duration duration,
}) async {
if (!active) return;
if (!enabled || casting || _stopped) {
if (_background) {
await invoke('backgroundPlaybackStop', null);
_background = false;
}
return;
}
await invoke(
_background ? 'backgroundPlaybackUpdate' : 'backgroundPlayback',
{
if (!_background) 'enabled': true,
'title': media()['title'] ?? '',
'playing': playing,
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
},
);
_background = true;
}
Future<void> stopBackground() async {
if (_background) {
await invoke('backgroundPlaybackStop', null);
_background = false;
}
}
}
+293
View File
@@ -0,0 +1,293 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import '../data/content_policy.dart';
import '../data/models.dart';
import '../state/app_state.dart';
import 'cast_media_share.dart';
import 'media_helpers.dart';
import 'media_platform.dart';
import 'native_player.dart';
/// Android playback uses a standalone LibVLC activity. Its exit future owns the
/// checkpoint and any device-only offline-package HTTP lease.
class NativePlaybackPage extends StatefulWidget {
const NativePlaybackPage({
super.key,
required this.title,
required this.source,
this.episode,
this.playlist = const [],
this.alternatives = const [],
this.localFile,
});
final TitleRecord title;
final MediaSource source;
final EpisodeRecord? episode;
final List<EpisodeRecord> playlist;
final List<MediaSource> alternatives;
final String? localFile;
@override
State<NativePlaybackPage> createState() => _NativePlaybackPageState();
}
class _NativePlaybackPageState extends State<NativePlaybackPage> {
late MediaSource _source = widget.source;
late EpisodeRecord? _episode = widget.episode;
AppState? _app;
bool _started = false, _opening = false;
String? _error;
int _generation = 0;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_app = AppScope.read(context);
if (!_started) {
_started = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(_open());
});
}
}
bool _current(int generation) => mounted && generation == _generation;
Duration _resume(EpisodeRecord? episode) {
final app = _app!;
final saved = app.history
.where(
(entry) =>
entry.title.libraryKey == widget.title.libraryKey &&
entry.episodeId == episode?.id,
)
.firstOrNull;
if (!app.prefs.resumePlayback || saved == null) return Duration.zero;
return resumePosition(
position: Duration(milliseconds: (saved.positionSeconds * 1000).round()),
duration: Duration(milliseconds: (saved.durationSeconds * 1000).round()),
rewindSeconds: app.prefs.resumeRewindSeconds,
);
}
Future<void> _open() async {
if (_opening) return;
final generation = ++_generation;
setState(() {
_opening = true;
_error = null;
});
try {
while (_current(generation)) {
final source = _source;
final episode = _episode;
if (!ContentPolicy.allowsTitleJson(widget.title.toJson()) ||
!ContentPolicy.allowsUrl(source.url)) {
throw StateError('This source is excluded from ReelBox.');
}
final prefs = _app!.prefs;
var target = widget.localFile ?? source.url;
CastMediaLease? lease;
// Native HLS/DASH reads need a device-only HTTP origin for package
// resources. Never bind a LAN interface or relay remote movie content.
if (widget.localFile != null &&
RegExp(
r'\.(m3u8|mpd)$',
caseSensitive: false,
).hasMatch(widget.localFile!)) {
lease = await CastMediaShare.prepare(
payload: {'url': source.url},
localFile: widget.localFile,
bindAddress: InternetAddress.loopbackIPv4,
advertisedHost: '127.0.0.1',
);
if (!_current(generation)) {
await lease.close();
return;
}
target = lease.payload['url'] as String;
}
NativePlaybackResult result;
try {
result = await NativePlayerBridge.open(
url: target,
title: episode == null
? widget.title.title
: '${widget.title.title} — ${episode.title}',
position: _resume(episode),
autoplay: prefs.autoplay,
externalPackage: prefs.externalPlayerPackage,
orientation: prefs.orientation,
videoFit: prefs.videoFit,
speed: prefs.playbackSpeed,
subtitleEnabled: prefs.subtitleEnabled,
);
} finally {
// The pending native call owns this lease, even if the Flutter route
// was removed. Revoke only after the activity has exited.
await lease?.close();
}
if (!_current(generation)) return;
// Launch success or an initial zero position is not evidence of viewing.
if (result.opened &&
result.position > Duration.zero &&
!source.isTrailer) {
await _app!.recordWatch(
widget.title,
episodeId: episode?.id,
episodeTitle: episode?.title ?? '',
sourceUrl: source.url,
positionSeconds: result.position.inMilliseconds / 1000,
durationSeconds: result.duration.inMilliseconds / 1000,
);
}
if (!_current(generation)) return;
if (!result.opened) {
throw StateError('The native decoder did not open this source.');
}
final next =
result.completed && prefs.autoNext && widget.localFile == null
? nextEpisode(widget.playlist, episode?.id)
: null;
final nextSource = next == null
? null
: selectSource(next.sources, preferredQuality: source.quality);
if (next != null && nextSource != null) {
setState(() {
_episode = next;
_source = nextSource;
});
continue;
}
setState(() => _opening = false);
if ((!result.completed || prefs.closeOnCompletion) && mounted) {
await Navigator.maybePop(context);
}
return;
}
} catch (_) {
if (_current(generation)) {
setState(() {
_opening = false;
_error = tr(
context,
'Could not play this source. Retry, choose another quality, or use an external player.',
'پخش این منبع ممکن نشد. دوباره تلاش کنید، کیفیت دیگری انتخاب کنید یا از پخش‌کننده خارجی استفاده کنید.',
);
});
}
}
}
Future<void> _external() async {
try {
if (widget.localFile != null &&
RegExp(
r'\.(m3u8|mpd)$',
caseSensitive: false,
).hasMatch(widget.localFile!)) {
throw UnsupportedError('Use the in-app player for an offline package.');
}
await MediaPlatform.openExternal(
widget.localFile ?? _source.url,
packageName: _app!.prefs.externalPlayerPackage,
title: widget.title.title,
position: _resume(_episode),
);
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
tr(
context,
'No external player could open this source.',
'پخش‌کننده خارجی نتوانست این منبع را باز کند.',
),
),
),
);
}
}
}
@override
void dispose() {
_generation++;
super.dispose();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(_episode?.title ?? widget.title.title)),
body: ListView(
padding: const EdgeInsets.all(24),
children: [
if (_opening) const Center(child: CircularProgressIndicator()),
const SizedBox(height: 24),
if (_error != null)
Text(
tr(context, 'Unable to play this source', 'پخش این منبع ممکن نیست'),
style: Theme.of(context).textTheme.titleLarge,
),
Text(
_error ??
tr(
context,
_opening
? 'Opening the embedded VLC player'
: 'Playback finished',
_opening
? 'در حال باز کردن پخش‌کننده داخلی VLC'
: 'پخش پایان یافت',
),
),
const SizedBox(height: 24),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.icon(
onPressed: _opening ? null : _open,
icon: const Icon(Icons.play_arrow),
label: Text(
tr(
context,
_error == null ? 'Play again' : 'Retry',
_error == null ? 'پخش دوباره' : 'تلاش دوباره',
),
),
),
TextButton.icon(
onPressed: _opening ? null : _external,
icon: const Icon(Icons.open_in_new),
label: Text(tr(context, 'External player', 'پخش‌کننده خارجی')),
),
],
),
if (!_opening && widget.localFile == null)
for (final source in (_episode?.sources ?? widget.alternatives).where(
(source) =>
source.url != _source.url &&
!source.isTrailer &&
isRemoteMedia(source.url) &&
ContentPolicy.allowsUrl(source.url),
))
ListTile(
title: Text(
source.quality.isEmpty
? tr(context, 'Another source', 'منبع دیگر')
: source.quality,
),
trailing: const Icon(Icons.play_arrow),
onTap: () {
_source = source;
unawaited(_open());
},
),
],
),
);
}
+114
View File
@@ -0,0 +1,114 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
/// Exit checkpoint from the embedded Android LibVLC activity. `opened` means
/// the decoder actually reached Playing, not merely that Android opened a view.
class NativePlaybackResult {
const NativePlaybackResult({
required this.position,
required this.duration,
required this.opened,
required this.completed,
});
final Duration position, duration;
final bool opened, completed;
factory NativePlaybackResult.fromMap(Map<dynamic, dynamic> value) {
int milliseconds(String key) {
final raw = value[key];
// Duration stores microseconds in a signed 64-bit integer on Android.
if (raw is! int ||
raw < 0 ||
raw > 0x7fffffffffffffff ~/ Duration.microsecondsPerMillisecond) {
throw FormatException('Invalid native playback $key');
}
return raw;
}
final position = milliseconds('positionMs');
final duration = milliseconds('durationMs');
if (value['opened'] is! bool || value['completed'] is! bool) {
throw const FormatException('Invalid native playback flags');
}
// An EOF flag alone must not advance the playlist. Validate the raw timing
// before its display bounds are applied, including unknown-duration input.
final atEnd =
duration > 0 &&
position > 0 &&
position <= duration &&
duration - position <= (duration ~/ 50).clamp(1, 2000);
return NativePlaybackResult(
position: Duration(
milliseconds: duration > 0 && position > duration ? duration : position,
),
duration: Duration(milliseconds: duration),
opened: value['opened'] as bool,
completed: value['completed'] == true && value['opened'] == true && atEnd,
);
}
}
/// Standalone native player, independent of the Flutter PlayerPage/decoder.
///
/// The future resolves ONLY when the activity exits. Keep any offline loopback
/// server lease alive until then; source URLs are forwarded byte-for-byte.
/// Callers own history persistence, ID association and local lease cleanup.
/// Validation/platform failures may throw. If Android cannot launch the activity,
/// the result has `opened: false`; no path fabricates successful playback.
class NativePlayerBridge {
static const _channel = MethodChannel('org.reelbox/media');
static bool get supported =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
static Future<NativePlaybackResult> open({
required String url,
required String title,
Duration position = Duration.zero,
bool autoplay = true,
String externalPackage = 'ask',
String orientation = 'auto',
String videoFit = 'contain',
double speed = 1.0,
bool subtitleEnabled = true,
String? subtitleUrl,
bool hardwareAcceleration = true,
bool pipEnabled = true,
}) async {
if (!supported) {
throw UnsupportedError('The embedded native player requires Android.');
}
if (url.trim().isEmpty) throw ArgumentError.value(url, 'url');
if (!speed.isFinite || speed < .25 || speed > 3) {
throw ArgumentError.value(speed, 'speed', 'Must be between 0.25 and 3');
}
if (!const ['auto', 'portrait', 'landscape'].contains(orientation)) {
throw ArgumentError.value(orientation, 'orientation');
}
if (!const ['contain', 'cover', 'fill'].contains(videoFit)) {
throw ArgumentError.value(videoFit, 'videoFit');
}
final response = await _channel.invokeMapMethod<dynamic, dynamic>(
'openNativePlayer',
{
'url': url,
'title': title,
'positionMs': position.inMilliseconds < 0 ? 0 : position.inMilliseconds,
'autoplay': autoplay,
'externalPackage': externalPackage,
'orientation': orientation,
'videoFit': videoFit,
'speed': speed,
'subtitleEnabled': subtitleEnabled,
'subtitleUrl': ?subtitleUrl,
'hardwareAcceleration': hardwareAcceleration,
'pipEnabled': pipEnabled,
},
);
if (response == null) {
throw const FormatException('Native player returned no exit checkpoint');
}
return NativePlaybackResult.fromMap(response);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+174
View File
@@ -0,0 +1,174 @@
/// Converts already-decoded SRT, SSA/ASS or WebVTT text to plain-cue WebVTT.
/// Deliberately drops styling, attachments, identifiers and positioning rather
/// than allowing receiver-side fetches or pretending to preserve ASS rendering.
class SubtitleWebVtt {
static String convert(String source) {
final text = source
.replaceFirst(RegExp(r'^\uFEFF'), '')
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.trim();
final cues = <_Cue>[];
if (RegExp(r'^\[Script Info\]', caseSensitive: false).hasMatch(text)) {
_ass(text, cues);
} else {
final blocks = text.split(RegExp(r'\n[ \t]*\n'));
final vtt = RegExp(r'^WEBVTT(?:[ \t]|$)')
.hasMatch(blocks.first.split('\n').first);
if (vtt) blocks.removeAt(0); // Header metadata is never propagated.
for (final block in blocks) {
final lines = block.trim().split('\n');
if (vtt &&
RegExp(r'^(?:STYLE|REGION|NOTE)(?:[ \t]|$)')
.hasMatch(lines.first)) {
continue;
}
if (lines.length > 1 && !lines.first.contains('-->')) {
if (!vtt && !RegExp(r'^\d+$').hasMatch(lines.first)) {
throw const FormatException(
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
);
}
lines.removeAt(0);
}
if (lines.length < 2) {
throw const FormatException('Invalid or empty subtitle cue.');
}
final timing = RegExp(r'^(\S+)[ \t]+-->[ \t]+(\S+)(?:[ \t]+.*)?$')
.firstMatch(lines.first);
if (timing == null) {
throw const FormatException(
'Unsupported subtitle format; use SRT, SSA/ASS or WebVTT.',
);
}
cues.add(
_Cue(
_time(timing[1]!, short: vtt),
_time(timing[2]!, short: vtt),
_plain(lines.skip(1).join('\n')),
),
);
}
}
if (cues.isEmpty) {
throw const FormatException('No supported subtitle cues.');
}
var previous = -1;
for (final cue in cues) {
if (cue.end <= cue.start || cue.start < previous || cue.body.isEmpty) {
throw const FormatException(
'Invalid subtitle cue timing/order or empty text.',
);
}
previous = cue.start;
}
return 'WEBVTT\n\n${cues.map((c) => '${_stamp(c.start)} --> ${_stamp(c.end)}\n${c.body}').join('\n\n')}\n\n';
}
static int _time(String value, {bool short = false, bool ass = false}) {
final expression = ass
? r'^(\d{1,6}):(\d{2}):(\d{2})\.(\d{2})$'
: r'^(\d{2,6}):(\d{2}):(\d{2})[.,](\d{3})$';
var match = RegExp(expression).firstMatch(value);
if (match == null && short) {
match = RegExp(r'^(\d{2}):(\d{2})\.(\d{3})$').firstMatch(value);
if (match != null) {
return _time('00:$value');
}
}
if (match == null) {
throw const FormatException('Invalid subtitle timestamp.');
}
final h = int.parse(match[1]!);
final m = int.parse(match[2]!);
final s = int.parse(match[3]!);
if (m > 59 || s > 59) {
throw const FormatException('Invalid subtitle timestamp range.');
}
return ((h * 60 + m) * 60 + s) * 1000 +
int.parse(match[4]!) * (ass ? 10 : 1);
}
static String _stamp(int ms) {
String pad(int value, int width) => '$value'.padLeft(width, '0');
return '${pad(ms ~/ 3600000, 2)}:${pad(ms ~/ 60000 % 60, 2)}:${pad(ms ~/ 1000 % 60, 2)}.${pad(ms % 1000, 3)}';
}
static void _ass(String text, List<_Cue> cues) {
var events = false;
List<String>? fields;
for (final raw in text.split('\n')) {
final line = raw.trim();
if (line.startsWith('[')) {
events = line.toLowerCase() == '[events]';
continue;
}
if (!events) continue;
if (line.toLowerCase().startsWith('format:')) {
fields = line
.substring(line.indexOf(':') + 1)
.split(',')
.map((s) => s.trim().toLowerCase())
.toList();
} else if (line.toLowerCase().startsWith('dialogue:')) {
if (fields == null ||
fields.last != 'text' ||
!fields.contains('start') ||
!fields.contains('end') ||
fields.toSet().length != fields.length) {
throw const FormatException('Invalid SSA/ASS Events format.');
}
final parts = line.substring(line.indexOf(':') + 1).split(',');
if (parts.length < fields.length) {
throw const FormatException('Incomplete SSA/ASS dialogue.');
}
final body = parts.skip(fields.length - 1).join(',');
if (RegExp(r'\\p[1-9]').hasMatch(body)) {
throw UnsupportedError('ASS drawing subtitles are not supported.');
}
cues.add(
_Cue(
_time(parts[fields.indexOf('start')].trim(), ass: true),
_time(parts[fields.indexOf('end')].trim(), ass: true),
_plain(
body
.replaceAll(r'\N', '\n')
.replaceAll(r'\n', '\n')
.replaceAll(r'\h', ' '),
),
),
);
}
}
}
static String _plain(String body) {
// Plain escaped text is a strict subset of WebVTT: no HTML resources,
// CSS, voice/class attributes, ASS overrides, or embedded timing tags.
return body
.replaceAll(
RegExp(
r'<(script|style)\b[^>]*>.*?</\1\s*>',
caseSensitive: false,
dotAll: true,
),
'',
)
.replaceAll(RegExp(r'\{[^}]*\}'), '')
.replaceAll(RegExp(r'<[^>]*>', dotAll: true), '')
.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.trim();
}
}
class _Cue {
const _Cue(this.start, this.end, this.body);
final int start, end;
final String body;
}
File diff suppressed because it is too large. Load diff
+75
View File
@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:path_provider/path_provider.dart';
class CacheSnapshot {
const CacheSnapshot({required this.memoryBytes, required this.diskBytes});
final int memoryBytes, diskBytes;
int get totalBytes => memoryBytes + diskBytes;
}
/// Disposable cache only. Never sweep the temporary/app-support directories:
/// downloads, pending packages and private library data may also live there.
class CacheService {
CacheService({Future<Directory> Function()? directory})
: _directory = directory ?? _defaultDirectory;
final Future<Directory> Function() _directory;
static Future<Directory> _defaultDirectory() async =>
Directory('${(await getTemporaryDirectory()).path}/reelbox-cache');
Future<CacheSnapshot> measure() async {
var diskBytes = 0;
if (!kIsWeb) {
final directory = await _directory();
// Ignore links even when the cache root itself is one.
if (await FileSystemEntity.type(directory.path, followLinks: false) ==
FileSystemEntityType.directory) {
await for (final entry in directory.list(
recursive: true,
followLinks: false,
)) {
if (await FileSystemEntity.type(entry.path, followLinks: false) ==
FileSystemEntityType.file) {
try {
diskBytes += await File(entry.path).length();
} on FileSystemException {
// An evicted file may disappear during measurement.
}
}
}
}
}
return CacheSnapshot(
memoryBytes: PaintingBinding.instance.imageCache.currentSizeBytes,
diskBytes: diskBytes,
);
}
Future<void> clear() async {
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
if (kIsWeb) return;
final directory = await _directory();
if (await FileSystemEntity.type(directory.path, followLinks: false) !=
FileSystemEntityType.directory) {
return;
}
// Only regular files. Do not follow or delete links to other storage.
await for (final entry in directory.list(
recursive: true,
followLinks: false,
)) {
if (await FileSystemEntity.type(entry.path, followLinks: false) ==
FileSystemEntityType.file) {
try {
await File(entry.path).delete();
} on FileSystemException {
if (await File(entry.path).exists()) rethrow;
}
}
}
}
}
+2363
View File
File diff suppressed because it is too large. Load diff
+865
View File
@@ -0,0 +1,865 @@
part of 'catalog.dart';
/// Account-free discovery, using the observed routes rather than invented tags.
class DiscoveryDirectoryPage extends StatelessWidget {
const DiscoveryDirectoryPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Explore more', 'کشف بیشتر'))),
body: ListView(
children: [
for (final preset in <(String, String, String, int, String)>[
('Best titles', 'بهترین عنوان‌ها', 'mixed', 0, 'rating'),
('Newest titles', 'جدیدترین عنوان‌ها', 'mixed', 0, 'created'),
('Popular titles', 'عنوان‌های محبوب', 'mixed', 0, 'rating'),
('Top 250 movies', '۲۵۰ فیلم برتر', 'movie', 0, 'imdb'),
('Top 250 series', '۲۵۰ سریال برتر', 'serie', 0, 'imdb'),
('Korean series', 'سریال‌های کره‌ای', 'serie', 36, 'created'),
(
'Chinese / Japanese series',
'سریال‌های چینی / ژاپنی',
'serie',
37,
'created',
),
('Turkish series', 'سریال‌های ترکی', 'serie', 35, 'created'),
('Indian series', 'سریال‌های هندی', 'serie', 38, 'created'),
('Indian movies', 'فیلم‌های هندی', 'movie', 38, 'created'),
(
'Newest movies by year',
'جدیدترین فیلم‌ها بر اساس سال',
'movie',
0,
'year',
),
(
'Newest series by year',
'جدیدترین سریال‌ها بر اساس سال',
'serie',
0,
'year',
),
if (!AppScope.of(context).usesIranCatalogue) ...[
('Most-viewed movies', 'پربازدیدترین فیلم‌ها', 'movie', 0, 'views'),
(
'Most-viewed series',
'پربازدیدترین سریال‌ها',
'serie',
0,
'views',
),
],
])
ListTile(
leading: const Icon(Icons.movie_filter_outlined),
title: Text(tr(context, preset.$1, preset.$2)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
BrowsePage(
initialKind: preset.$3,
initialGenre: preset.$4,
initialOrder: preset.$5,
heading: tr(context, preset.$1, preset.$2),
),
),
),
const Divider(),
for (final entry in <(String, String, Widget)>[
(
'Miniseries',
'مینی سریال',
const SearchPage(initialQuery: 'مینی سریال'),
),
('Sitcoms', 'سیتکام', const SearchPage(initialQuery: 'سیتکام')),
('Actors', 'بازیگران', const ActorsPage()),
('Countries', 'کشورها', const CountriesPage()),
(
'Themes & subjects',
'موضوع‌ها',
const SubjectDirectoryPage(kind: DiscoveryDirectory.themes),
),
(
'Franchises',
'فرنچایزها',
const SubjectDirectoryPage(kind: DiscoveryDirectory.franchises),
),
(
'Most-shared titles',
'عنوان‌های بیشتر اشتراک‌گذاشته‌شده',
const SharedTitlesPage(),
),
(
'New discoveries',
'تازه‌های کشف',
const SharedTitlesPage(newest: true),
),
('Editor picks', 'پیشنهاد سردبیر', const CuratedPage()),
(
'Best collections',
'مجموعه‌های برتر',
const CuratedPage(best: true),
),
(
'Best by year',
'بهترین‌های هر سال',
const RecommendationsPage(byYear: true),
),
])
ListTile(
title: Text(tr(context, entry.$1, entry.$2)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(context, entry.$3),
),
],
),
);
}
class _DiscoveryHomeShelves extends StatefulWidget {
const _DiscoveryHomeShelves();
@override
State<_DiscoveryHomeShelves> createState() => _DiscoveryHomeShelvesState();
}
class _DiscoveryHomeShelvesState extends State<_DiscoveryHomeShelves> {
Future<List<Map<String, dynamic>>>? _actors;
Future<List<Tag>>? _countries;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final api = AppScope.read(context).api;
_actors ??= api.actors();
_countries ??= api.countries();
}
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
child: Row(
children: [
Expanded(
child: _heading(
context,
tr(context, 'Popular actors', 'بازیگران محبوب'),
),
),
TextButton(
onPressed: () => _push(context, const ActorsPage()),
child: Text(tr(context, 'View all', 'نمایش همه')),
),
],
),
),
AsyncResult<List<Map<String, dynamic>>>(
future: _actors!,
onRetry: () => setState(() {
_actors = AppScope.read(context).api.actors();
}),
builder: (items) => items.isEmpty
? const SizedBox.shrink()
: SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: _PeopleGrid(items: items.take(12).toList()),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
child: Row(
children: [
Expanded(
child: _heading(
context,
tr(context, 'Discover by country', 'کشف بر اساس کشور'),
),
),
TextButton(
onPressed: () => _push(context, const CountriesPage()),
child: Text(tr(context, 'View all', 'نمایش همه')),
),
],
),
),
AsyncResult<List<Tag>>(
future: _countries!,
onRetry: () => setState(() {
_countries = AppScope.read(context).api.countries();
}),
builder: (items) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Wrap(
spacing: 8,
runSpacing: 4,
children: [
for (final country
in items
.where(
(tag) =>
tag.id > 0 && ContentPolicy.allowsText(tag.title),
)
.take(16))
ActionChip(
label: Text(country.title),
avatar: const Icon(Icons.public, size: 16),
onPressed: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialCountry: country.id,
heading: country.title,
),
),
),
],
),
),
),
],
);
}
class CountriesPage extends StatefulWidget {
const CountriesPage({super.key});
@override
State<CountriesPage> createState() => _CountriesPageState();
}
class _CountriesPageState extends State<CountriesPage> {
Future<List<Tag>>? _future;
String _query = '';
@override
void didChangeDependencies() {
super.didChangeDependencies();
_future ??= AppScope.read(context).api.countries();
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Countries', 'کشورها'))),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
onChanged: (value) =>
setState(() => _query = value.trim().toLowerCase()),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: tr(context, 'Find a country', 'جستجوی کشور'),
),
),
),
Expanded(
child: AsyncResult<List<Tag>>(
future: _future!,
onRetry: () => setState(() {
_future = AppScope.read(context).api.countries();
}),
builder: (items) {
final visible = items
.where(
(tag) =>
tag.id > 0 &&
ContentPolicy.allowsText(tag.title) &&
tag.title.toLowerCase().contains(_query),
)
.toList();
return visible.isEmpty
? EmptyView(
title: tr(
context,
'No countries found',
'کشوری پیدا نشد',
),
)
: ListView(
children: [
for (final country in visible)
ListTile(
leading: const Icon(Icons.public),
title: Text(country.title),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
BrowsePage(
initialKind: 'mixed',
initialCountry: country.id,
heading: country.title,
),
),
),
],
);
},
),
),
],
),
);
}
class ActorsPage extends StatefulWidget {
const ActorsPage({super.key});
@override
State<ActorsPage> createState() => _ActorsPageState();
}
class _ActorsPageState extends State<ActorsPage> {
String query = '';
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(tr(context, 'Actors', 'بازیگران'))),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
textInputAction: TextInputAction.search,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: tr(context, 'Search actors', 'جستجوی بازیگران'),
),
onSubmitted: (value) => setState(() => query = value.trim()),
),
),
Expanded(
child: _PagedObjects(
key: ValueKey(query),
load: (page) =>
AppScope.read(context).api.actors(page: page, search: query),
builder: (items) => _PeopleGrid(items: items),
),
),
],
),
);
}
class _PeopleGrid extends StatelessWidget {
const _PeopleGrid({required this.items});
final List<Map<String, dynamic>> items;
@override
Widget build(BuildContext context) => Wrap(
spacing: 8,
runSpacing: 12,
children: [
for (final person
in items
.map(actorMetadata)
.where((row) => ContentPolicy.allowsText('${row['name']}')))
SizedBox(
width: 125,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _push(context, ActorTitlesPage(person: person)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
ClipOval(
child: SizedBox(
width: 80,
height: 80,
child: _text(person, ['image']).isEmpty
? const ColoredBox(
color: Color(0xFF252733),
child: Icon(Icons.person_outline, size: 44),
)
: MediaArtwork(url: _text(person, ['image'])),
),
),
const SizedBox(height: 8),
Text(
_text(person, ['name']),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (_text(person, ['role']).isNotEmpty)
Text(
ContentPolicy.sanitizeText(_text(person, ['role'])),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
),
),
],
);
}
class ActorTitlesPage extends StatefulWidget {
const ActorTitlesPage({super.key, required this.person});
final Map<String, dynamic> person;
@override
State<ActorTitlesPage> createState() => _ActorTitlesPageState();
}
class _ActorTitlesPageState extends State<ActorTitlesPage> {
ActorFilmography? _loader;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final person = actorMetadata(widget.person);
_loader ??= ActorFilmography(
CatalogDiscovery(AppScope.read(context).api),
id: _id(person, ['id']),
name: _text(person, ['name']),
);
}
@override
Widget build(BuildContext context) {
final person = actorMetadata(widget.person);
return Scaffold(
appBar: AppBar(
title: Text(ContentPolicy.sanitizeText(_text(person, ['name']))),
),
body: _PagedTitles(
load: _loader!.load,
headerBuilder: () => Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipOval(
child: SizedBox(
width: 86,
height: 86,
child: _text(person, ['image']).isEmpty
? const Icon(Icons.person_outline, size: 60)
: MediaArtwork(url: _text(person, ['image'])),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final field in [
('role', 'Role', 'نقش'),
('born', 'Born', 'تولد'),
('height', 'Height', 'قد'),
])
if (_text(person, [field.$1]).isNotEmpty)
Text(
'${tr(context, field.$2, field.$3)}: ${ContentPolicy.sanitizeText(_text(person, [field.$1]))}',
),
],
),
),
],
),
if (_text(person, ['bio']).isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 16),
child: _Synopsis(
text: ContentPolicy.sanitizeText(_text(person, ['bio'])),
),
),
if (_loader!.usingNameFallback)
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
tr(
context,
'Filmography unavailable. Showing title search by actor name.',
'فیلموگرافی موجود نیست. نتایج جستجوی نام بازیگر نمایش داده می‌شود.',
),
),
),
],
),
),
),
);
}
}
class SubjectDirectoryPage extends StatelessWidget {
const SubjectDirectoryPage({super.key, required this.kind});
final DiscoveryDirectory kind;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
kind == DiscoveryDirectory.themes
? tr(context, 'Themes & subjects', 'موضوع‌ها')
: tr(context, 'Franchises', 'فرنچایزها'),
),
),
body: _PagedObjects(
load: (page) =>
CatalogDiscovery(AppScope.read(context).api)
.directory(kind, page: page),
builder: (items) => Column(
children: [
for (final item in items.where(
(row) => ContentPolicy.allowsText(_text(row, ['title'])),
))
ListTile(
leading: Icon(
kind == DiscoveryDirectory.themes
? Icons.topic_outlined
: Icons.collections_bookmark_outlined,
),
title: Text(_text(item, ['title'])),
subtitle:
kind == DiscoveryDirectory.franchises &&
_text(item, ['img']).isNotEmpty
? Text(ContentPolicy.sanitizeText(_text(item, ['img'])))
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
SearchPage(initialQuery: _text(item, ['title'])),
),
),
],
),
),
);
}
class SharedTitlesPage extends StatelessWidget {
const SharedTitlesPage({super.key, this.newest = false});
final bool newest;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
newest
? tr(context, 'New discoveries', 'تازه‌های کشف')
: tr(
context,
'Most-shared titles',
'عنوان‌های بیشتر اشتراک‌گذاشته‌شده',
),
),
),
body: _PagedTitles(
load: (page) =>
CatalogDiscovery(AppScope.read(context).api)
.sharedTitles(page: page, newest: newest),
),
);
}
class CuratedPage extends StatefulWidget {
const CuratedPage({super.key, this.best = false});
final bool best;
@override
State<CuratedPage> createState() => _CuratedPageState();
}
class _CuratedPageState extends State<CuratedPage> {
Future<List<Map<String, dynamic>>>? future;
@override
void didChangeDependencies() {
super.didChangeDependencies();
future ??= AppScope.read(context).api.curatedLinks(best: widget.best);
}
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(
widget.best
? tr(context, 'Best collections', 'مجموعه‌های برتر')
: tr(context, 'Editor picks', 'پیشنهاد سردبیر'),
),
),
body: AsyncResult<List<Map<String, dynamic>>>(
future: future!,
onRetry: () => setState(() {
future = AppScope.read(context).api.curatedLinks(best: widget.best);
}),
builder: (items) {
final visible = items
.where(
(row) =>
ContentPolicy.allowsText(_text(row, ['title'])) &&
ContentPolicy.allowsUrl(_text(row, ['link'])),
)
.toList();
return visible.isEmpty
? EmptyView(
title: tr(
context,
'No collections returned',
'مجموعه‌ای دریافت نشد',
),
)
: ListView(
children: [
for (final item in visible)
ListTile(
leading: const Icon(Icons.auto_awesome_outlined),
title: Text(_text(item, ['title'])),
trailing: const Icon(Icons.chevron_right),
onTap: () => _push(
context,
_CuratedContents(
title: _text(item, ['title']),
link: _text(item, ['link']),
),
),
),
],
);
},
),
);
}
class _CuratedContents extends StatelessWidget {
const _CuratedContents({required this.title, required this.link});
final String title, link;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(title)),
body: _PagedTitles(
load: (page) =>
AppScope.read(context).api.curatedContent(link, page: page),
),
);
}
class _PagedObjects extends StatefulWidget {
const _PagedObjects({super.key, required this.load, required this.builder});
final Future<List<Map<String, dynamic>>> Function(int) load;
final Widget Function(List<Map<String, dynamic>>) builder;
@override
State<_PagedObjects> createState() => _PagedObjectsState();
}
class _PagedObjectsState extends State<_PagedObjects> {
final items = <Map<String, dynamic>>[];
final _scroll = ScrollController();
int page = 1, _emptyPages = 0;
bool busy = false, end = false;
Object? error;
@override
void initState() {
super.initState();
_scroll.addListener(_nearEnd);
load();
}
@override
void dispose() {
_scroll.dispose();
super.dispose();
}
void _nearEnd() {
if (_emptyPages < 4 &&
error == null &&
_scroll.hasClients &&
_scroll.position.extentAfter < 350) {
load();
}
}
Future<void> load() async {
if (busy || end) return;
setState(() {
busy = true;
error = null;
});
try {
final result = await widget.load(page);
if (!mounted) return;
final known = items.map(jsonEncode).toSet();
final fresh = result.where((row) => known.add(jsonEncode(row))).toList();
setState(() {
items.addAll(fresh);
end = result is DiscoveryObjectPage
? result.nextPage == null
: result.isEmpty;
page = result is DiscoveryObjectPage
? result.nextPage ?? page
: page + 1;
_emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0;
});
} catch (_) {
if (mounted) {
setState(
() => error = const ApiException('Could not load. Please retry.'),
);
}
} finally {
if (mounted) {
setState(() => busy = false);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _nearEnd();
});
}
}
}
Future<void> _refresh() async {
if (busy) return;
setState(() {
items.clear();
page = 1;
end = false;
error = null;
_emptyPages = 0;
});
await load();
}
@override
Widget build(BuildContext context) => RefreshIndicator(
onRefresh: _refresh,
child: ListView(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: [
Text(
'${items.length} ${tr(context, 'items loaded', 'مورد بارگذاری شده')}',
),
widget.builder(items),
if (busy)
const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
),
if (error != null)
EmptyView(
title: tr(context, 'Could not load', 'بارگذاری ممکن نشد'),
onRetry: load,
),
if (!busy && error == null && end && items.isEmpty)
EmptyView(
title: tr(
context,
'Nothing returned by the service',
'سرویس موردی برنگرداند',
),
onRetry: _refresh,
),
if (!busy && !end && error == null)
OutlinedButton(
onPressed: () {
_emptyPages = 0;
load();
},
child: Text(tr(context, 'Load more', 'نمایش بیشتر')),
),
],
),
);
}
class _ContinueWatchingShelf extends StatelessWidget {
const _ContinueWatchingShelf();
@override
Widget build(BuildContext context) {
final known = <String>{};
final entries = AppScope.of(context).history
.where(
(entry) =>
entry.positionSeconds >= 5 &&
!entry.title.isChannel &&
ContentPolicy.allowsTitleJson(entry.title.toJson()) &&
known.add(entry.title.libraryKey),
)
.take(6)
.toList();
if (entries.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(20),
child: _heading(
context,
tr(context, 'Continue watching', 'ادامه تماشا'),
),
),
for (final entry in entries)
ListTile(
leading: const Icon(Icons.play_circle_outline),
title: Text(entry.title.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (entry.episodeTitle.isNotEmpty) Text(entry.episodeTitle),
LinearProgressIndicator(value: entry.progress),
Text('${(entry.progress * 100).round()}%'),
],
),
onTap: () => _continueCurrentHistory(context, entry),
trailing: IconButton(
tooltip: tr(context, 'Title details', 'جزئیات عنوان'),
icon: const Icon(Icons.info_outline),
onPressed: () => goTitle(context, entry.title),
),
),
],
);
}
}
Future<void> _continueCurrentHistory(
BuildContext context,
WatchEntry entry,
) async {
try {
final api = AppScope.read(context).api;
final title = await api.poster(entry.title.id);
if (!ContentPolicy.allowsTitleJson(title.toJson())) {
throw const ApiException('Unavailable');
}
EpisodeRecord? episode;
var playlist = <EpisodeRecord>[];
if (entry.episodeId != null) {
playlist = (await api.seasons(title.id))
.expand((season) => season.episodes)
.toList();
episode = playlist
.where((item) => item.id == entry.episodeId)
.firstOrNull;
if (episode == null) throw const ApiException('Episode unavailable');
}
final sources = (episode?.sources ?? title.sources)
.where((source) => ContentPolicy.allowsUrl(source.url))
.toList();
final source = currentHistorySource(sources, entry.sourceUrl);
if (source == null) throw const ApiException('Source unavailable');
if (context.mounted) {
await _play(
context,
title,
source,
episode: episode,
playlist: playlist,
alternatives: sources,
);
}
} catch (_) {
if (context.mounted) {
toast(
context,
tr(
context,
'Could not refresh the current playback source. Open title details and retry.',
'به‌روزرسانی منبع پخش ممکن نشد. جزئیات عنوان را باز کنید و دوباره تلاش کنید.',
),
);
}
}
}
+504
View File
@@ -0,0 +1,504 @@
import 'package:flutter/material.dart';
import '../data/models.dart';
import '../data/content_policy.dart';
import '../state/app_state.dart';
import 'catalog.dart';
enum ToastKind { info, success, error }
void toast(
BuildContext context,
String message, {
ToastKind kind = ToastKind.info,
}) {
if (!context.mounted) return;
final colors = Theme.of(context).colorScheme;
final text = ContentPolicy.sanitizeText(message);
final background = switch (kind) {
ToastKind.error => colors.errorContainer,
ToastKind.success => colors.tertiaryContainer,
ToastKind.info => colors.inverseSurface,
};
final foreground = switch (kind) {
ToastKind.error => colors.onErrorContainer,
ToastKind.success => colors.onTertiaryContainer,
ToastKind.info => colors.onInverseSurface,
};
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: background,
showCloseIcon: true,
closeIconColor: foreground,
content: Row(
children: [
Icon(
switch (kind) {
ToastKind.error => Icons.error_outline,
ToastKind.success => Icons.check_circle_outline,
ToastKind.info => Icons.info_outline,
},
color: foreground,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
text.isEmpty
? tr(
context,
'This content is not available',
'این محتوا در دسترس نیست',
)
: text,
style: TextStyle(color: foreground),
),
),
],
),
),
);
}
void goTitle(BuildContext context, TitleRecord title) {
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
title.id <= 0 ||
title.title.isEmpty) {
toast(
context,
tr(context, 'This content is not available', 'این محتوا در دسترس نیست'),
);
return;
}
Navigator.of(context)
.push(MaterialPageRoute<void>(builder: (_) => TitlePage(title: title)));
}
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
required this.title,
this.message,
this.icon = Icons.movie_outlined,
this.onRetry,
});
final String title;
final String? message;
final IconData icon;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) => Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 44, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 18),
Text(
title,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 10),
Text(
message!,
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
if (onRetry != null) ...[
const SizedBox(height: 20),
FilledButton.tonalIcon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: Text(tr(context, 'Try again', 'تلاش دوباره')),
),
],
],
),
),
);
}
class AsyncResult<T> extends StatelessWidget {
const AsyncResult({
super.key,
required this.future,
required this.builder,
this.onRetry,
});
final Future<T> future;
final Widget Function(T) builder;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) => FutureBuilder<T>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: Padding(
padding: EdgeInsets.all(48),
child: CircularProgressIndicator(),
),
);
}
if (snapshot.hasError) {
return EmptyView(
title: tr(
context,
'Could not load this page',
'بارگذاری صفحه ممکن نشد',
),
message: snapshot.error.toString(),
icon: Icons.wifi_off_rounded,
onRetry: onRetry,
);
}
if (!snapshot.hasData) {
return EmptyView(
title: tr(context, 'Nothing here yet', 'هنوز محتوایی وجود ندارد'),
);
}
return builder(snapshot.data as T);
},
);
}
class MediaArtwork extends StatelessWidget {
const MediaArtwork({
super.key,
required this.url,
this.fit = BoxFit.cover,
this.width,
this.height,
});
final String url;
final BoxFit fit;
final double? width, height;
@override
Widget build(BuildContext context) {
Widget fallback() => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.local_movies_outlined,
color: Theme.of(context).colorScheme.onSurfaceVariant,
size: 32,
),
),
);
final dataSaver =
context
.dependOnInheritedWidgetOfExactType<AppScope>()
?.notifier
?.prefs
.dataSaver ??
false;
if (url.isEmpty || dataSaver || !ContentPolicy.allowsUrl(url)) {
return SizedBox(width: width, height: height, child: fallback());
}
return LayoutBuilder(
builder: (context, constraints) {
// A parent SizedBox often supplies thumbnail dimensions instead of the
// optional widget width. Decode for that slot, not the 480px fallback.
final logicalWidth = width ??
(constraints.hasBoundedWidth ? constraints.maxWidth : null);
final cacheWidth = logicalWidth == null || !logicalWidth.isFinite
? 480
: (logicalWidth * MediaQuery.devicePixelRatioOf(context))
.ceil()
.clamp(1, 1200);
return Image.network(
url,
fit: fit,
width: width,
height: height,
cacheWidth: cacheWidth,
errorBuilder: (_, _, _) =>
SizedBox(width: width, height: height, child: fallback()),
loadingBuilder: (_, child, progress) =>
progress == null ? child : fallback(),
);
},
);
}
}
class PosterCard extends StatelessWidget {
const PosterCard({super.key, required this.title, this.width = 132});
final TitleRecord title;
final double width;
@override
Widget build(BuildContext context) {
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
title.id <= 0 ||
title.title.isEmpty) {
return const SizedBox.shrink();
}
final state = AppScope.of(context);
return SizedBox(
width: width,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => goTitle(context, title),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
MediaArtwork(url: title.image, width: width),
if (title.imdb > 0)
PositionedDirectional(
top: 8,
start: 8,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .78),
borderRadius: BorderRadius.circular(7),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
child: Text(
'★ ${title.imdb.toStringAsFixed(1)}',
style: const TextStyle(
fontSize: 11,
color: Color(0xFFFFD777),
fontWeight: FontWeight.w700,
),
),
),
),
),
PositionedDirectional(
bottom: 6,
end: 6,
child: IconButton.filledTonal(
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
padding: EdgeInsets.zero,
iconSize: 18,
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
onPressed: () async {
try {
await state.toggleSaved(title);
} catch (error) {
if (context.mounted) {
toast(
context,
error.toString(),
kind: ToastKind.error,
);
}
}
},
icon: Icon(
state.hasSaved(title.id, kind: title.type)
? Icons.bookmark_rounded
: Icons.bookmark_border_rounded,
),
),
),
],
),
),
),
const SizedBox(height: 9),
Text(
title.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 3),
Text(
[
if (title.year > 0) '${title.year}',
title.isSeries
? tr(context, 'Series', 'سریال')
: tr(context, 'Movie', 'فیلم'),
].join(' · '),
maxLines: 1,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
class PosterRail extends StatelessWidget {
const PosterRail({
super.key,
required this.title,
required this.items,
this.onMore,
});
final String title;
final List<TitleRecord> items;
final VoidCallback? onMore;
@override
Widget build(BuildContext context) {
final visible = items
.where(
(item) =>
item.id > 0 &&
item.title.isNotEmpty &&
ContentPolicy.allowsTitleJson(item.toJson()),
)
.toList();
if (visible.isEmpty || !ContentPolicy.allowsText(title)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
children: [
Padding(
padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 12, 12),
child: Row(
children: [
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
),
if (onMore != null)
TextButton(
onPressed: onMore,
child: Text(tr(context, 'See all', 'نمایش همه')),
),
],
),
),
SizedBox(
height: 244,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20),
scrollDirection: Axis.horizontal,
itemCount: visible.length,
separatorBuilder: (_, _) => const SizedBox(width: 13),
itemBuilder: (_, index) => PosterCard(title: visible[index]),
),
),
],
),
);
}
}
class PosterGrid extends StatelessWidget {
const PosterGrid({super.key, required this.items, this.sliver = false});
final List<TitleRecord> items;
/// Compose directly into the owning viewport instead of measuring every row.
final bool sliver;
@override
Widget build(BuildContext context) {
final visible = items
.where(
(item) =>
item.id > 0 &&
item.title.isNotEmpty &&
ContentPolicy.allowsTitleJson(item.toJson()),
)
.toList();
if (visible.isEmpty) {
return sliver
? const SliverToBoxAdapter(child: SizedBox.shrink())
: const SizedBox.shrink();
}
final state = AppScope.of(context);
final Widget content;
if (state.prefs.compactCatalog) {
content = SliverList.separated(
itemCount: visible.length,
separatorBuilder: (_, _) => const Divider(height: 8),
itemBuilder: (context, index) {
final title = visible[index];
return ListTile(
key: ValueKey('compact-title-row:${title.libraryKey}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
minTileHeight: 96,
leading: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: MediaArtwork(url: title.image, width: 56, height: 80),
),
title: Text(
title.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
if (title.year > 0) '${title.year}',
title.isSeries
? tr(context, 'Series', 'سریال')
: tr(context, 'Movie', 'فیلم'),
if (title.imdb > 0) 'IMDb ${title.imdb.toStringAsFixed(1)}',
].join(' · '),
),
onTap: () => goTitle(context, title),
trailing: IconButton(
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
icon: Icon(
state.hasSaved(title.id, kind: title.type)
? Icons.bookmark
: Icons.bookmark_outline,
),
onPressed: () async {
try {
await state.toggleSaved(title);
} catch (error) {
if (context.mounted) {
toast(context, error.toString(), kind: ToastKind.error);
}
}
},
),
);
},
);
} else {
content = SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 190,
childAspectRatio: .56,
mainAxisSpacing: 20,
crossAxisSpacing: 14,
),
itemCount: visible.length,
itemBuilder: (_, index) => PosterCard(title: visible[index]),
);
}
final padded = SliverPadding(
padding: const EdgeInsets.all(16),
sliver: content,
);
return sliver
? padded
: CustomScrollView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
slivers: [padded],
);
}
}
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../data/home_banners.dart';
import '../state/app_state.dart';
import 'common.dart';
/// Independent of home metadata loading/error UI so the official channel stays
/// visible and usable even before configuration has arrived.
class HomeBanners extends StatelessWidget {
const HomeBanners({super.key, required this.future});
final Future<List<HomeBanner>> future;
Future<void> _open(BuildContext context, Uri target) async {
try {
if (await launchUrl(target, mode: LaunchMode.externalApplication)) return;
} catch (_) {
// Do not expose platform exceptions, configuration values or URLs.
}
if (context.mounted) {
toast(
context,
tr(context, 'Could not open this link', 'باز کردن پیوند ممکن نشد'),
kind: ToastKind.error,
);
}
}
@override
Widget build(BuildContext context) => FutureBuilder<List<HomeBanner>>(
future: future,
builder: (context, snapshot) {
final banners = [
HomeBanner.telegram,
if (!snapshot.hasError)
...?snapshot.data?.where((item) => item.isAdvertisement).take(5),
];
return Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
child: Column(
children: [
for (var i = 0; i < banners.length; i++)
Padding(
padding: EdgeInsets.only(
bottom: i == banners.length - 1 ? 0 : 12,
),
child: _BannerCard(
banner: banners[i],
tapKey: Key(
banners[i].isAdvertisement
? 'home-banner-ad-$i'
: 'home-banner-telegram',
),
onTap: () => _open(context, banners[i].target),
),
),
],
),
);
},
);
}
class _BannerCard extends StatelessWidget {
const _BannerCard({
required this.banner,
required this.tapKey,
required this.onTap,
});
final HomeBanner banner;
final Key tapKey;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final state = AppScope.of(context);
final persian = state.locale == 'fa';
final colors = Theme.of(context).colorScheme;
final subtitle = banner.subtitle(persian);
return Material(
color: colors.primary.withValues(alpha: .08),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colors.primary.withValues(alpha: .22)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: tapKey,
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (banner.image != null && !state.prefs.dataSaver)
Image.network(
banner.image.toString(),
height: 112,
fit: BoxFit.cover,
cacheWidth: 700,
excludeFromSemantics: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(
banner.isAdvertisement
? Icons.ad_units_outlined
: Icons.campaign_outlined,
color: colors.primary,
size: 18,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (banner.isAdvertisement) ...[
Text(
tr(context, 'Advertisement', 'تبلیغات'),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colors.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
],
Text(
banner.title(persian),
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colors.onSurfaceVariant),
),
],
],
),
),
const SizedBox(width: 8),
Icon(
Icons.open_in_new_rounded,
color: colors.primary,
size: 18,
),
],
),
),
],
),
),
);
}
}
+1518
View File
File diff suppressed because it is too large. Load diff
+878
View File
@@ -0,0 +1,878 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_file_picker:
dependency: transitive
description:
name: android_file_picker
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
android_intent_plus:
dependency: "direct main"
description:
name: android_intent_plus
sha256: "4d4bbd619d698cbee3c400ba89246ecb0f525146b0ae05389f62ede47abd5bec"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
archive:
dependency: "direct main"
description:
name: archive
sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb
url: "https://pub.dev"
source: hosted
version: "4.6.0"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
background_downloader:
dependency: "direct main"
description:
path: "third_party/background_downloader"
relative: true
source: path
version: "9.6.5"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: d7749fd36d8b404a4935612b94eca7c3ce3705377ab678e24ff2a4301436f741
url: "https://pub.dev"
source: hosted
version: "7.3.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6
url: "https://pub.dev"
source: hosted
version: "0.3.5+5"
crypto:
dependency: "direct main"
description:
name: crypto
sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4
url: "https://pub.dev"
source: hosted
version: "3.0.8"
dbus:
dependency: transitive
description:
name: dbus
sha256: f7db8504e715b835f5ebe5fe74604ccd961d504c73389a5d8e81bafc2e2f3612
url: "https://pub.dev"
source: hosted
version: "0.8.0"
dio:
dependency: "direct main"
description:
name: dio
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.dev"
source: hosted
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
url: "https://pub.dev"
source: hosted
version: "13.3.1"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
url: "https://pub.dev"
source: hosted
version: "2.4.1"
file_picker_linux:
dependency: transitive
description:
name: file_picker_linux
sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da
url: "https://pub.dev"
source: hosted
version: "2.0.2"
file_picker_platform_interface:
dependency: transitive
description:
name: file_picker_platform_interface
sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20"
url: "https://pub.dev"
source: hosted
version: "4.2.0"
file_picker_web:
dependency: transitive
description:
name: file_picker_web
sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c
url: "https://pub.dev"
source: hosted
version: "4.2.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
intl:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
jni:
dependency: transitive
description:
name: jni
sha256: d8df6dab6847855512fe44dd9032b0bbe34dda7bf7597e41d30750879779813f
url: "https://pub.dev"
source: hosted
version: "1.1.1"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191"
url: "https://pub.dev"
source: hosted
version: "1.0.4+1"
jni_util:
dependency: transitive
description:
name: jni_util
sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: "direct dev"
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
media_kit:
dependency: "direct main"
description:
name: media_kit
sha256: ae9e79597500c7ad6083a3c7b7b7544ddabfceacce7ae5c9709b0ec16a5d6643
url: "https://pub.dev"
source: hosted
version: "1.2.6"
media_kit_libs_android_video:
dependency: "direct main"
description:
name: media_kit_libs_android_video
sha256: "3f6274e5ab2de512c286a25c327288601ee445ed8ac319e0ef0b66148bd8f76c"
url: "https://pub.dev"
source: hosted
version: "1.3.8"
media_kit_video:
dependency: "direct main"
description:
name: media_kit_video
sha256: afaa509e7b7e0bf247557a3a740cde903a52c34ace9810f94500e127bd7b043d
url: "https://pub.dev"
source: hosted
version: "2.0.1"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2ae480aa3379716b8bb58dfebb168e2919394c92b629efaaa0baaa5d140786e6"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d
url: "https://pub.dev"
source: hosted
version: "3.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908"
url: "https://pub.dev"
source: hosted
version: "10.2.2"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4
url: "https://pub.dev"
source: hosted
version: "4.1.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.dev"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: "direct dev"
description:
name: path_provider_linux
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.dev"
source: hosted
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "2f1564aadb14215e478563b5406cb80729d022158c01dc50c96013c2f7eb4790"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
platform:
dependency: transitive
description:
name: platform
sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec
url: "https://pub.dev"
source: hosted
version: "3.2.0"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
posix:
dependency: transitive
description:
name: posix
sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63"
url: "https://pub.dev"
source: hosted
version: "6.5.4"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
safe_local_storage:
dependency: transitive
description:
name: safe_local_storage
sha256: "76288b689a3e1fddccb377177feed85d73fdfc64c9c3c52991e2d9aba35c4857"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: db40767f130189d53f3ae8a6393c217ff4a492da1baff718a9873679c0c00224
url: "https://pub.dev"
source: hosted
version: "13.3.1"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "365ef7379fc22507256adda3385152942ffce08935452bc972c2e52a0bebae41"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "33b38b07d9ac7d16aff0426c61418752fd3e7a8c7dc8be786c875d6ce673b002"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea"
url: "https://pub.dev"
source: hosted
version: "2.5.7"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: b3d516dbd3757ff2a458735e4f6cbac9ff261f576f6e87bcb17feabb39e657c5
url: "https://pub.dev"
source: hosted
version: "3.5.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
universal_platform:
dependency: transitive
description:
name: universal_platform
sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
uri_parser:
dependency: transitive
description:
name: uri_parser
sha256: "051c62e5f693de98ca9f130ee707f8916e2266945565926be3ff20659f7853ce"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda
url: "https://pub.dev"
source: hosted
version: "6.3.3"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610"
url: "https://pub.dev"
source: hosted
version: "6.3.33"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a"
url: "https://pub.dev"
source: hosted
version: "6.4.2"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0"
url: "https://pub.dev"
source: hosted
version: "3.2.3"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201"
url: "https://pub.dev"
source: hosted
version: "3.2.6"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd"
url: "https://pub.dev"
source: hosted
version: "4.6.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78"
url: "https://pub.dev"
source: hosted
version: "15.4.0"
wakelock_plus:
dependency: "direct main"
description:
name: wakelock_plus
sha256: "39a1d99085cf3190d41305bca05f0c0d77522f355f7f99bb648ee231192d9d68"
url: "https://pub.dev"
source: hosted
version: "1.8.2"
wakelock_plus_platform_interface:
dependency: transitive
description:
name: wakelock_plus_platform_interface
sha256: "764c25504562abc8ac3406f5d175f126b276e6ba916c7c2675690b73368ca384"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
web:
dependency: transitive
description:
name: web
sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
win32:
dependency: transitive
description:
name: win32
sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d
url: "https://pub.dev"
source: hosted
version: "6.4.0"
windows_file_picker:
dependency: transitive
description:
name: windows_file_picker
sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: "direct main"
description:
name: xml
sha256: "0fe8cc98946def31cbcbc8734aa4067031e6f65ea3a78a508060bfed2200bfeb"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.13.0 <4.0.0"
flutter: ">=3.44.0"
+49
View File
@@ -0,0 +1,49 @@
name: reelbox
description: An independent film and series client with a device-local library.
publish_to: none
version: 0.4.4+8
environment:
sdk: ^3.13.0
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
dio: 5.11.1
shared_preferences: 2.5.6
path_provider: 2.1.6
media_kit: 1.2.6
media_kit_video: 2.0.1
media_kit_libs_android_video: 1.3.8
background_downloader: 9.6.5
url_launcher: 6.3.3
share_plus: 13.3.1
file_picker: 13.3.1
wakelock_plus: 1.8.2
android_intent_plus: 6.1.0
package_info_plus: 10.2.2
xml: 7.1.0
crypto: 3.0.8
archive: 4.6.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: 6.0.0
logging: 1.3.0
# Host-only real-worker test registration; not an Android runtime dependency.
path_provider_linux: 2.2.2
# Pinned 9.6.5 with narrowly scoped multipart resume corrections.
dependency_overrides:
background_downloader:
path: third_party/background_downloader
flutter:
uses-material-design: true
assets:
- assets/licenses/LGPL-2.1.txt
- assets/licenses/LIBVLC-NOTICE.txt
+3
View File
@@ -0,0 +1,3 @@
{
"banners": []
}
+431
View File
@@ -0,0 +1,431 @@
"""Read-only ReelBox metadata service. No upstream or media fetches."""
from collections import OrderedDict
from datetime import datetime, timezone
import json
from pathlib import Path
import re
import sqlite3
import sys
import threading
import unicodedata
class ApiError(Exception):
def __init__(self, status, message):
super().__init__(message)
self.status = status
_FOLD = dict.fromkeys(i for i in range(0x110000)
if unicodedata.category(chr(i)) in {'Mn', 'Cf'})
_FOLD.update({ord('ي'): 'ی', ord('ى'): 'ی', ord('ك'): 'ک'})
def normal(value):
return str(value or '').lower().translate(_FOLD)
def allowed_text(value):
text = normal(unicodedata.normalize('NFKC', str(value or '')))
# Upstream notices are often disguised as movie rows, with new IDs after
# refreshes. Match their labels rather than IDs or bare English "notice",
# which would incorrectly hide films such as Red Notice / Notice to Quit.
label = re.sub(r'[\W_ـ]', '', text)
if label.startswith((
'اطلاعیه', 'اطلاعرسانی', 'بهروزرسانی', 'بروزرسانی',
'اعلانجدید', 'اعلانمهم', 'اعلانفوری', 'اعلامیهجدید', 'اعلامیهمهم',
'آپدیتجدید', 'اپدیتجدید',
'newannouncement', 'importantannouncement', 'urgentannouncement',
'newnotice', 'importantnotice', 'urgentnotice',
'appupdate', 'applicationupdate', 'softwareupdate')):
return False
compact = re.sub(r'[\s_\-ـ]', '', text)
return not (any(word in compact for word in
('ملودیباکس', 'ملودیبکس', 'melodybox', 'melodibox', 'نورتون', 'نورتن', 'norton',
'موویباکس', 'موسویباکس', 'نسخهجدیدهورتون', 'اپلیکیشنهورتون', 'نرمافزارهورتون'))
or 'تلگرام' in text or re.search(r'\btelegram\b|(^|[\s>(])@[a-z][a-z0-9_]{3,}', text)
or re.search(r'(دانلود|نصب|دریافت).{0,30}نسخه.{0,18}(برنامه|اپلیکیشن|سیمبا|مووی)', text)
or re.search(r'(install|download|update).{0,24}(latest|new).{0,12}(app|version)', text))
def banner_https(value):
from urllib.parse import urlsplit
if (not isinstance(value, str) or len(value) > 2048
or re.search(r'[\x00-\x20\x7f\\]', value)
or re.search(r'%(?![0-9a-f]{2})|%(?:0[0-9a-f]|1[0-9a-f]|7f)', value, re.I)):
return False
try:
uri = urlsplit(value)
return (uri.scheme == 'https' and bool(uri.hostname) and uri.username is None
and uri.password is None and uri.port in (None, 443)
and len(uri.hostname) <= 253
and all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', part)
for part in uri.hostname.split('.')))
except ValueError:
return False
def load_banners(path):
"""Read a bounded operator-owned file, never a remote config or admin input."""
try:
with Path(path).open('rb') as source:
data = source.read(65537)
if len(data) > 65536:
return {'banners': []}
config = json.loads(data)
except (OSError, ValueError, RecursionError):
return {'banners': []}
if not isinstance(config, dict) or not isinstance(config.get('banners'), list):
return {'banners': []}
result = []
for row in config['banners'][:20]:
if len(result) == 5:
break
if not isinstance(row, dict) or not banner_https(row.get('target_url')):
continue
image = row.get('image_url')
if image not in (None, '') and not banner_https(image):
continue
clean = {}
for key, fallback, limit in (
('title_en', '', 80), ('title_fa', row.get('title_en', ''), 80),
('subtitle_en', '', 180), ('subtitle_fa', row.get('subtitle_en', ''), 180)):
text = row.get(key, fallback)
if (not isinstance(text, str) or len(text) > limit
or re.search(r'[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]', text)
or not allowed_text(text)):
break
clean[key] = text.strip()
else:
if not clean['title_en'] or not clean['title_fa']:
continue
clean['target_url'] = row['target_url']
if image:
clean['image_url'] = image
result.append(clean)
return {'banners': result}
class Catalog:
RESPONSE_CACHE_BYTES = 8 * 1024 * 1024
RESPONSE_CACHE_ENTRIES = 128
RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024
def __init__(self, path, banner_config=None):
self.banner_config = Path(banner_config) if banner_config is not None else Path(__file__).with_name('banners.json')
self.db = sqlite3.connect(Path(path).resolve().as_uri() + '?mode=ro&immutable=1',
uri=True, check_same_thread=False)
self.db.execute('PRAGMA query_only=ON')
self.db.execute('PRAGMA cache_size=-8192')
self.lock = threading.RLock()
self._responses = OrderedDict()
self._response_bytes = 0
self.metadata = {k: json.loads(v) for k, v in self.db.execute('SELECT * FROM metadata')}
# The snapshot is immutable until service restart. Normalize searchable
# text once instead of redoing Unicode folding for every row/request.
# Bound retained strings on the 256 MiB service; overflow stays correct
# through the original normalization path rather than dropping results.
self.search_text = {}
self.search_index_bytes = 0
visible = set()
for title_id, kind, title, description in self.db.execute(
'SELECT id,type,title,description FROM titles'):
if kind not in {'movie', 'serie'} or not allowed_text(title) or not allowed_text(description):
continue
visible.add(title_id)
text = (normal(title), normal(description))
size = sys.getsizeof(text) + sum(sys.getsizeof(v) for v in text) + sys.getsizeof(title_id) + 64
if self.search_index_bytes + size <= 64 * 1024 * 1024:
self.search_text[title_id] = text
self.search_index_bytes += size
self.visible = frozenset(visible)
self.db.create_function('visible', 1, lambda value: value in self.visible, deterministic=True)
self.db.create_function('search_match', 4, self.search_match, deterministic=True)
tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources')
self.counts = {name: self.db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables}
self.counts['visible_titles'] = len(self.visible)
def search_match(self, title_id, title, description, query):
text = self.search_text.get(title_id)
if text is None:
text = (normal(title), normal(description))
return query in text[0] or query in text[1]
def close(self):
self.db.close()
def health(self):
built = self.metadata.get('built_at_utc')
age = None
if built:
age = max(0, int((datetime.now(timezone.utc) - datetime.fromisoformat(built)).total_seconds()))
return {'status': 'ok', 'counts': self.counts.copy(),
'snapshot_built_at_utc': built, 'snapshot_age_seconds': age,
'provenance': self.metadata.get('provenance', 'Exported API snapshot'),
'offline': True, 'media_proxy': False,
'snapshot_limitations': ['Not live-synchronised', 'Media URLs may expire',
'Cast, public lists and subtitle metadata were not exported']}
@staticmethod
def clean(value):
if isinstance(value, dict):
return {k: Catalog.clean(v) for k, v in value.items()
if k.lower() not in {'comment', 'comments', 'comments_enabled', 'replies'}}
if isinstance(value, list):
return [Catalog.clean(v) for v in value]
return value
def rows(self, sql, params=()):
return [self.clean(json.loads(row[0])) for row in self.db.execute(sql, params)]
def tags(self, name):
columns = 'id,title,image' if name == 'countries' else 'id,title'
cursor = self.db.execute('SELECT ' + columns + ' FROM ' + name + ' ORDER BY id')
keys = [column[0] for column in cursor.description]
return [dict(zip(keys, row)) for row in cursor
if allowed_text(row[1]) and not (name == 'genres' and row[0] == 48)]
def titles(self, kind='poster', genre=0, country=0, order='created', page=0, query=None):
orders = {'created': 't.id DESC', 'updated': 't.id DESC',
'imdb': 't.imdb DESC,t.id DESC', 'rating': 't.rating DESC,t.id DESC',
'year': 't.year DESC,t.id DESC', 'title': 't.title COLLATE NOCASE,t.id',
'random': '(t.id * 2654435761) % 2147483647,t.id'}
if order not in orders or kind not in {'movie', 'serie', 'poster'} or not 0 <= page <= 100000:
raise ApiError(400, 'Unsupported catalog filter')
where, params = ['visible(t.id)'], []
if kind != 'poster':
where.append('t.type=?')
params.append(kind)
for table, column, value in [('title_genres', 'genre_id', genre),
('title_countries', 'country_id', country)]:
if value:
where.append('EXISTS (SELECT 1 FROM ' + table + ' f WHERE f.title_id=t.id AND f.' + column + '=?)')
params.append(value)
if query is not None:
where.append('search_match(t.id,t.title,t.description,?)')
params.append(normal(query))
return self.rows('SELECT t.raw_json FROM titles t WHERE ' + ' AND '.join(where)
+ ' ORDER BY ' + orders[order] + ' LIMIT 30 OFFSET ?', params + [page * 30])
def home(self):
return {'genres': [
{'id': -2, 'title': 'Latest in catalogue', 'posters': self.titles()},
{'id': -3, 'title': 'Highest IMDb ratings', 'posters': self.titles(order='imdb')},
{'id': -4, 'title': 'Series in catalogue', 'posters': self.titles(kind='serie')}
], 'actors': [], 'countries': self.tags('countries'), 'slides': []}
def legacy(self, path, query):
from urllib.parse import parse_qs
try:
raw = parse_qs(query, keep_blank_values=True, max_num_fields=8)
except ValueError:
raise ApiError(400, 'Invalid query') from None
if any(len(values) != 1 for values in raw.values()):
raise ApiError(400, 'Repeated query parameter')
args = {key: values[0] for key, values in raw.items()}
allowed = {'action', 'q', 'pageno'}
if path.endswith('/filter.php'):
allowed = {'action', 'garid', 'pageno'}
elif path.endswith('/star.php'):
allowed = {'key', 'action', 'q', 'pageno'}
elif path.endswith(('/pishnahad.php', '/bartar.php')):
allowed = set()
if args.keys() - allowed or len(args.get('q', '')) > 200 or len(args.get('key', '')) > 256:
raise ApiError(400, 'Unsupported query parameter')
try:
page = int(args.get('pageno') or 1) - 1
genre = int(args.get('garid') or 0)
except ValueError:
raise ApiError(400, 'Invalid page or genre') from None
if not 0 <= page <= 100000 or not 0 <= genre <= 999999999:
raise ApiError(400, 'Invalid page or genre')
action = args.get('action', '')
if path.endswith(('/pishnahad.php', '/bartar.php')):
return [{'title': [tag['title']],
'link': ['/api-user/newapi/filter.php?action=pishnahad&garid=' + str(tag['id']) + '&pageno=']}
for tag in self.tags('genres')
if self.db.execute('SELECT 1 FROM title_genres WHERE genre_id=? AND visible(title_id) LIMIT 1', (tag['id'],)).fetchone()]
if path == '/api-user/newapi/filter.php' and action == 'pishnahad':
return self.titles(genre=genre, order='imdb', page=page)
if path == '/api-user/newapi/like.php':
if action in {'search-movie', 'search-movie-en'}:
return self.titles(query=args.get('q', '').strip(), page=page) if args.get('q', '').strip() else []
if action in {'best-rand-top', 'best-rand-year'}:
return self.titles(order='year' if action.endswith('year') else 'imdb', page=page)
if action in {'all-actor', 'colection', 'search-trand'}:
return []
if path == '/api-user/filmahang/star.php' and action in {'starsearch', 'mazmon', 'share', 'new'}:
return []
raise ApiError(404, 'Metadata route not available in snapshot')
def response(self, target):
# Only immutable REST snapshot metadata. Health/banner freshness and
# search privacy are preserved: no search query is retained in this LRU.
cacheable = (target.startswith('/api/') and '?' not in target
and target.rstrip('/') not in {'/api/health', '/api/banners'})
with self.lock:
cached = self._responses.get(target) if cacheable else None
if cached is not None:
self._responses.move_to_end(target)
return cached
body = json.dumps(self.dispatch(target), ensure_ascii=False,
separators=(',', ':')).encode('utf-8')
if len(body) > 32 * 1024 * 1024:
raise ApiError(503, 'Metadata response exceeds safety limit')
size = len(body) + len(target.encode('utf-8'))
if (cacheable and size <= self.RESPONSE_CACHE_ITEM_BYTES
and size <= self.RESPONSE_CACHE_BYTES and self.RESPONSE_CACHE_ENTRIES > 0):
while self._responses and (
self._response_bytes + size > self.RESPONSE_CACHE_BYTES
or len(self._responses) >= self.RESPONSE_CACHE_ENTRIES):
old_target, old_body = self._responses.popitem(last=False)
self._response_bytes -= len(old_body) + len(old_target.encode('utf-8'))
self._responses[target] = body
self._response_bytes += size
return body
def dispatch(self, target):
from urllib.parse import urlsplit
if len(target) > 4096 or re.search(r'[\x00-\x20\x7f]', target):
raise ApiError(400, 'Invalid metadata route')
try:
parsed = urlsplit(target)
except ValueError:
raise ApiError(400, 'Invalid metadata route') from None
path = parsed.path
if (len(target) > 4096 or parsed.scheme or parsed.netloc or parsed.fragment
or '%' in path or '\\' in path or re.search(r'[\x00-\x20\x7f]', path)
or re.search(r'(^|/)\.{1,2}(/|$)', path)):
raise ApiError(400, 'Invalid metadata route')
key = r'[A-Za-z0-9_-]{0,256}'
with self.lock:
if path in {'/api-user/newapi/like.php', '/api-user/newapi/filter.php',
'/api-user/newapi/pishnahad.php', '/api-user/newapi/bartar.php',
'/api-user/filmahang/star.php'}:
return self.legacy(path, parsed.query)
if parsed.query:
raise ApiError(400, 'Unexpected query parameters')
if path in {'/api/banners', '/api/banners/'}:
return load_banners(self.banner_config)
if path in {'/api/health', '/api/health/'}:
return self.health()
if re.fullmatch(r'/api/first/' + key + '/', path):
return self.home()
if re.fullmatch(r'/api/genre/all/' + key + '/', path):
return self.tags('genres')
if re.fullmatch(r'/api/country/all/' + key + '/', path):
return self.tags('countries')
match = re.fullmatch(r'/api/(movie|serie|poster)/by/filtres/(\d{1,9})/(?:(\d{1,9})/)?([a-z]+)/([0-9]{1,6})/' + key + '/', path)
if match:
kind, genre, country, order, page = match.groups()
return self.titles(kind, int(genre), int(country or 0), order, int(page))
match = re.fullmatch(r'/api/movie/by/([0-9]{1,10})/' + key + '/', path)
if match:
title_id = int(match[1])
if title_id not in self.visible:
raise ApiError(404, 'Title not in snapshot')
item = self.rows('SELECT raw_json FROM titles WHERE id=?', (title_id,))[0]
item['sources'] = self.rows('SELECT raw_json FROM movie_sources WHERE title_id=? ORDER BY source_index', (title_id,))
return item
match = re.fullmatch(r'/api/season/by/serie/([0-9]{1,10})/' + key + '/', path)
if match:
title_id = int(match[1])
if title_id not in self.visible:
raise ApiError(404, 'Title not in snapshot')
return self.rows('SELECT raw_json FROM seasons WHERE series_id=? ORDER BY season_index', (title_id,))
if re.fullmatch(r'/api/(?:role/by/poster|movie/by/actor|subtitles/by/(?:movie|episode))/[0-9]{1,10}/' + key + '/', path):
return []
raise ApiError(404, 'Unknown metadata route')
def make_server(catalog, host='127.0.0.1', port=18765):
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class Handler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.0'
def setup(self):
self.request.settimeout(10)
super().setup()
def log_message(self, *_args):
pass # Never record source links, search terms or shared route keys.
def reply(self, status, value):
body = value if isinstance(value, bytes) else json.dumps(
value, ensure_ascii=False, separators=(',', ':')).encode('utf-8')
if len(body) > 32 * 1024 * 1024:
status, body = 503, b'{"error":"Metadata response exceeds safety limit"}'
self.send_response_only(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Cache-Control', 'no-store')
self.send_header('Connection', 'close')
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
self.close_connection = True
def do_GET(self):
try:
if self.headers.get('Transfer-Encoding') or self.headers.get('Content-Length', '0') != '0':
raise ApiError(400, 'Request body not supported')
self.reply(200, catalog.response(self.path))
except ApiError as error:
self.reply(error.status, {'error': str(error)})
except (BrokenPipeError, ConnectionResetError, TimeoutError):
self.close_connection = True
except Exception:
self.reply(503, {'error': 'Catalogue temporarily unavailable'})
do_HEAD = do_GET
def reject_write(self):
self.reply(405, {'error': 'Read-only metadata API'})
do_POST = do_PUT = do_DELETE = do_PATCH = do_CONNECT = do_OPTIONS = do_TRACE = reject_write
class Server(ThreadingHTTPServer):
daemon_threads = True
request_queue_size = 16
def __init__(self, address):
self.slots = threading.BoundedSemaphore(16)
super().__init__(address, Handler)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
if host != '127.0.0.1':
raise ValueError('Bind behind the HTTPS reverse proxy on IPv4 loopback only')
return Server((host, port))
def main():
import os
catalog = Catalog(os.environ.get('REELBOX_CATALOG_DB', '/var/lib/reelbox-api/catalog.sqlite'),
banner_config=os.environ.get('REELBOX_BANNER_CONFIG', '/var/lib/reelbox-api/banners.json'))
server = make_server(catalog)
try:
server.serve_forever()
finally:
server.server_close()
catalog.close()
if __name__ == '__main__':
main()
+419
View File
@@ -0,0 +1,419 @@
"""Metadata-only catalogue synchronization; publication is the caller's job.
Only fixed-origin, allowlisted metadata GETs are possible. Image/video URLs are
opaque stored metadata and are never followed. Supply the key from the calling
process (e.g. REELBOX_UPSTREAM_KEY). Reports and errors contain no request URLs.
"""
from contextlib import closing
from datetime import datetime, timezone
import http.client
import json
import math
import os
from pathlib import Path
import re
import sqlite3
import ssl
import time
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
UPSTREAM_BASE = 'https://ninwinios.info/api/'
MAX_RESPONSE_BYTES = 32 * 1024 * 1024
_ROUTE = re.compile(r'(?:genre/all|country/all|(?:movie|serie)/by/filtres/0/created/[0-9]{1,6}'
r'|movie/by/[1-9][0-9]{0,9}|season/by/serie/[1-9][0-9]{0,9})')
class SyncError(RuntimeError):
"""Safe, key-free error suitable for a scheduler log."""
class _NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
raise SyncError('Upstream redirect denied')
def _invalid_constant(_value):
raise ValueError('Non-finite JSON number')
class Upstream:
"""Single-worker rate-limited client. ``attempts`` is total attempts, <=10.
``opener`` is an injectable trusted urllib-compatible opener for tests.
``cache_dir`` is reserved; responses are intentionally not cached on disk,
so a subsequent run always refreshes metadata. No response/key is logged.
"""
def __init__(self, key, interval=0.25, timeout=30, attempts=3, opener=None, cache_dir=None):
if not isinstance(key, str) or not key or len(key) > 4096:
raise ValueError('Upstream key required')
if (type(attempts) is not int or not 1 <= attempts <= 10
or not isinstance(interval, (int, float)) or not math.isfinite(interval) or interval < 0
or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0):
raise ValueError('Invalid upstream retry or timing configuration')
self._key = key
self.interval, self.timeout, self.attempts = interval, timeout, attempts
self._opener = opener if opener is not None else build_opener(_NoRedirect())
self.requests_count = 0
self._last_request = None
def get(self, path):
if not isinstance(path, str) or not _ROUTE.fullmatch(path):
raise ValueError('Unsupported upstream metadata route')
url = UPSTREAM_BASE + path + '/' + quote(self._key, safe='') + '/'
parsed = urlsplit(url)
if (parsed.scheme, parsed.netloc, parsed.query, parsed.fragment) != ('https', 'ninwinios.info', '', ''):
raise ValueError('Unsupported upstream origin')
request = Request(url, headers={'Accept': 'application/json', 'Accept-Encoding': 'identity'}, method='GET')
for attempt in range(self.attempts):
if self._last_request is not None:
delay = self.interval - (time.monotonic() - self._last_request)
if delay > 0:
time.sleep(delay)
self._last_request = time.monotonic()
self.requests_count += 1
try:
with self._opener.open(request, timeout=self.timeout) as response:
status = response.status
if status == 429 or 500 <= status <= 599:
raise HTTPError(url, status, 'Upstream temporarily unavailable', {}, None)
if status != 200 or response.geturl() != url:
raise SyncError('Upstream status or redirect denied')
media_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
# The original PHP API labels valid JSON as text/html.
# Only allow this known legacy label; parsing/schema checks still apply.
if media_type and media_type not in {'application/json', 'text/html'} and not media_type.endswith('+json'):
raise SyncError('Upstream did not return JSON metadata')
declared = response.headers.get('Content-Length')
if declared is not None:
if not re.fullmatch(r'[0-9]{1,12}', declared) or int(declared) > MAX_RESPONSE_BYTES:
raise SyncError('Invalid upstream metadata length')
body = response.read(MAX_RESPONSE_BYTES + 1)
if len(body) > MAX_RESPONSE_BYTES:
raise SyncError('Upstream metadata response exceeds safety limit')
if declared is not None and len(body) != int(declared):
raise SyncError('Truncated upstream metadata response')
value = json.loads(body, parse_constant=_invalid_constant)
detail = path.startswith('movie/by/') and '/filtres/' not in path
if (detail and not isinstance(value, dict)) or (not detail and not isinstance(value, list)):
raise SyncError('Unexpected upstream metadata shape')
return value
except HTTPError as error:
retry = error.code == 429 or 500 <= error.code <= 599
error.close()
if not retry or attempt + 1 == self.attempts:
raise SyncError('Upstream HTTP request failed') from None
except (ssl.SSLError, ValueError, RecursionError):
raise SyncError('Invalid upstream TLS or JSON metadata') from None
except (URLError, OSError, http.client.HTTPException) as error:
if isinstance(getattr(error, 'reason', None), ssl.SSLError) or attempt + 1 == self.attempts:
raise SyncError('Upstream transport request failed') from None
if attempt + 1 < self.attempts:
time.sleep(min(2 ** attempt, 8))
raise SyncError('Upstream request exhausted')
try:
from .catalog_api import allowed_text
except ImportError: # Also support `PYTHONPATH=server; import catalog_sync`.
from catalog_api import allowed_text
def _encode(value):
return json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False)
def _id(value):
if type(value) is not int or not 1 <= value <= 9999999999:
raise SyncError('Malformed metadata identifier')
return value
def _objects(value):
if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
raise SyncError('Malformed metadata list')
return value
def _merge(old, incoming):
result = dict(old)
for key, value in incoming.items():
if key in {'sources', 'episodes', 'genres', 'country'}:
result[key] = _merge_sequence(old.get(key, []), value)
elif key not in result or (value is not None and value != ''):
result[key] = value
return result
def _merge_sequence(old, incoming):
"""Retain old positions; stable IDs win, missing IDs fall back to index."""
old, incoming = _objects(old), _objects(incoming)
result = list(old)
by_id = {item['id']: index for index, item in enumerate(old) if item.get('id') is not None}
seen_ids, matched = set(), set()
for index, item in enumerate(incoming):
identity = item.get('id')
if identity is not None:
_id(identity)
if identity in seen_ids:
raise SyncError('Duplicate nested metadata identifier')
seen_ids.add(identity)
target = by_id.get(identity)
if target is None and index < len(old) and old[index].get('id') is None:
target = index
else:
target = index if index < len(old) else None
if target is not None:
if target in matched:
raise SyncError('Ambiguous nested metadata identity')
matched.add(target)
result[target] = _merge(old[target], item)
else:
result.append(_merge({}, item))
return result
def _validate_record(item, kind, require_title=False):
if not isinstance(item, dict) or item.get('error') or item.get('errors'):
raise SyncError('Malformed metadata record')
if item.get('id') is not None:
_id(item['id'])
if kind in {'season', 'episode', 'source'} and item.get('id') is None:
identity = item.get('url') if kind == 'source' else item.get('title')
if not isinstance(identity, str) or not identity.strip():
raise SyncError('Nested metadata has no usable identity')
if require_title and (not isinstance(item.get('title'), str) or not item['title'].strip()):
raise SyncError('New title has no usable name')
for field in ('title', 'description', 'duration', 'classification', 'image', 'cover',
'downloadas', 'playas', 'type', 'quality', 'url'):
if field in item and item[field] is not None and not isinstance(item[field], str):
raise SyncError('Malformed text metadata field')
for field in ('year', 'imdb', 'rating'):
value = item.get(field)
if value is not None and (type(value) not in {int, float} or not math.isfinite(value)):
raise SyncError('Malformed numeric metadata field')
if 'comment' in item and item['comment'] is not None and type(item['comment']) not in {bool, int}:
raise SyncError('Malformed comment metadata flag')
for field, nested_kind in [('sources', 'source'), ('episodes', 'episode'),
('genres', 'reference'), ('country', 'reference')]:
if field in item:
for nested in _objects(item[field]):
_validate_record(nested, nested_kind)
def _reference(db, table, item):
_validate_record(item, 'reference')
_id(item.get('id'))
columns = ['id', 'title', 'image'] if table == 'countries' else ['id', 'title']
db.execute('INSERT INTO ' + table + '(' + ','.join(columns) + ') VALUES (' +
','.join('?' for _ in columns) + ') ON CONFLICT(id) DO UPDATE SET ' +
','.join(c + '=COALESCE(excluded.' + c + ',' + table + '.' + c + ')' for c in columns[1:]),
[item.get(c) for c in columns])
def _save_title(db, incoming):
title_id = _id(incoming.get('id'))
record = db.execute('SELECT raw_json FROM titles WHERE id=?', (title_id,)).fetchone()
_validate_record(incoming, 'title', require_title=not record)
old = json.loads(record[0]) if record else {}
merged = _merge(old, incoming)
raw = _encode(merged)
if record and old == merged:
return False
fields = ['id', 'type', 'title', 'description', 'year', 'imdb', 'rating', 'duration',
'classification', 'image', 'cover', 'downloadas', 'playas', 'comments_enabled', 'raw_json']
values = [merged.get(c) for c in fields]
values[-2], values[-1] = merged.get('comment'), raw
db.execute('INSERT INTO titles(' + ','.join(fields) + ') VALUES (' + ','.join('?' for _ in fields) +
') ON CONFLICT(id) DO UPDATE SET ' + ','.join(c + '=excluded.' + c for c in fields[1:]), values)
for field, table, junction, column in [('genres', 'genres', 'title_genres', 'genre_id'),
('country', 'countries', 'title_countries', 'country_id')]:
for tag in _objects(merged.get(field, [])):
_reference(db, table, tag)
db.execute('INSERT OR IGNORE INTO ' + junction + '(title_id,' + column + ') VALUES (?,?)',
(title_id, tag['id']))
for index, source in enumerate(_objects(merged.get('sources', []))):
db.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?) ON CONFLICT(title_id,source_index) '
'DO UPDATE SET source_id=excluded.source_id,quality=excluded.quality,'
'media_type=excluded.media_type,url=excluded.url,raw_json=excluded.raw_json',
(title_id, index, source.get('id'), source.get('quality'), source.get('type'),
source.get('url'), _encode(source)))
return True
def _save_seasons(db, series_id, incoming):
for season in _objects(incoming):
_validate_record(season, 'season')
previous = db.execute('SELECT season_index,raw_json FROM seasons WHERE series_id=? ORDER BY season_index',
(series_id,)).fetchall()
old = [json.loads(raw) for _, raw in previous]
merged = _merge_sequence(old, incoming)
changed = False
# Existing indices are retained even if an exported snapshot has gaps.
indices = [index for index, _ in previous]
next_index = max(indices, default=-1) + 1
indices += list(range(next_index, next_index + len(merged) - len(indices)))
for position, season in enumerate(merged):
if position < len(old) and season == old[position]:
continue
changed = True
si = indices[position]
db.execute('INSERT INTO seasons VALUES (?,?,?,?,?) ON CONFLICT(series_id,season_index) DO UPDATE SET '
'id=excluded.id,title=excluded.title,raw_json=excluded.raw_json',
(series_id, si, season.get('id'), season.get('title'), _encode(season)))
for ei, episode in enumerate(_objects(season.get('episodes', []))):
raw = _encode(episode)
current = db.execute('SELECT raw_json FROM episodes WHERE series_id=? AND season_index=? AND episode_index=?',
(series_id, si, ei)).fetchone()
if current and json.loads(current[0]) == episode:
continue
db.execute('INSERT INTO episodes VALUES (?,?,?,?,?,?,?,?,?,?) '
'ON CONFLICT(series_id,season_index,episode_index) DO UPDATE SET '
'id=excluded.id,title=excluded.title,description=excluded.description,duration=excluded.duration,'
'downloadas=excluded.downloadas,playas=excluded.playas,raw_json=excluded.raw_json',
(series_id, si, ei, episode.get('id'), episode.get('title'), episode.get('description'),
episode.get('duration'), episode.get('downloadas'), episode.get('playas'), raw))
for source_index, source in enumerate(_objects(episode.get('sources', []))):
db.execute('INSERT INTO episode_sources VALUES (?,?,?,?,?,?,?,?,?) '
'ON CONFLICT(series_id,season_index,episode_index,source_index) DO UPDATE SET '
'source_id=excluded.source_id,quality=excluded.quality,media_type=excluded.media_type,'
'url=excluded.url,raw_json=excluded.raw_json',
(series_id, si, ei, source_index, source.get('id'), source.get('quality'),
source.get('type'), source.get('url'), _encode(source)))
db.execute('INSERT INTO series_fetch_status VALUES (?,?,?,?) ON CONFLICT(series_id) DO UPDATE SET '
'status=excluded.status,season_count=excluded.season_count,source_url=excluded.source_url',
(series_id, 'fetched', len(merged), UPSTREAM_BASE + f'season/by/serie/{series_id}/'))
return changed
def _counts(db):
tables = ('titles', 'genres', 'countries', 'movie_sources', 'seasons', 'episodes', 'episode_sources')
counts = {name: db.execute('SELECT COUNT(*) FROM ' + name).fetchone()[0] for name in tables}
for name, kind in [('movies', 'movie'), ('series', 'serie')]:
counts[name] = db.execute('SELECT COUNT(*) FROM titles WHERE type=?', (kind,)).fetchone()[0]
counts['sources'] = counts['movie_sources'] + counts['episode_sources']
counts['visible_titles'] = sum(1 for kind, title, description in db.execute('SELECT type,title,description FROM titles')
if kind in {'movie', 'serie'} and allowed_text(title) and allowed_text(description))
return counts
def synchronize(source_db: Path, staged_db: Path, upstream, max_pages=2000, progress=None) -> dict:
"""Copy a snapshot into an exclusively created candidate and refresh metadata.
Never publishes or mutates the source. A failed candidate is removed. The
caller owns locking, free-space checks, atomic publication and API reload.
``progress`` accepts dictionaries containing only phase/count/total.
Both catalogue types must reach an empty page within ``max_pages`` each;
all visible series are refreshed, including those absent from those pages.
The returned updated-series IDs are a sorted sample of at most 100, with
``updated_series_count`` reporting the full count. No media is fetched.
"""
if type(max_pages) is not int or not 1 <= max_pages <= 1000000:
raise ValueError('Invalid catalogue page limit')
source_db, staged_db = Path(source_db), Path(staged_db)
if source_db.resolve() == staged_db.resolve():
raise ValueError('Candidate must differ from source')
created, db = False, None
start_requests = getattr(upstream, 'requests_count', None)
def emit(phase, count, total):
if progress is not None:
progress({'phase': phase, 'count': count, 'total': total})
try:
emit('copy', 0, 1)
with closing(sqlite3.connect(source_db.resolve().as_uri() + '?mode=ro', uri=True)) as source:
fd = os.open(staged_db, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
created = True
os.close(fd)
db = sqlite3.connect(staged_db)
source.backup(db)
emit('copy', 1, 1)
db.execute('PRAGMA foreign_keys=ON')
db.execute('PRAGMA journal_mode=DELETE')
db.execute('BEGIN IMMEDIATE')
before = _counts(db)
metadata = {k: json.loads(v) for k, v in db.execute('SELECT * FROM metadata')}
for count, (route, table) in enumerate([('genre/all', 'genres'), ('country/all', 'countries')], 1):
for item in _objects(upstream.get(route)):
_reference(db, table, item)
emit('references', count, 2)
updated_series = set()
for kind in ('movie', 'serie'):
seen = set()
for page in range(max_pages):
items = _objects(upstream.get(f'{kind}/by/filtres/0/created/{page}'))
emit(kind + '_pages', page + 1, max_pages)
if not items:
break
for item in items:
title_id = _id(item.get('id'))
if title_id in seen:
continue
seen.add(title_id)
if item.get('type') == 'channel':
continue
if item.get('type') != kind:
raise SyncError('Unexpected catalogue title type')
if not allowed_text(item.get('title')) or not allowed_text(item.get('description')):
continue
exists = db.execute('SELECT type FROM titles WHERE id=?', (title_id,)).fetchone()
if exists and exists[0] != kind:
raise SyncError('Catalogue title type changed')
if kind == 'movie' and not exists:
detail = upstream.get(f'movie/by/{title_id}')
if not isinstance(detail, dict) or detail.get('id') != title_id or detail.get('type') != kind:
raise SyncError('Malformed movie detail')
item = _merge(item, detail)
if not allowed_text(item.get('title')) or not allowed_text(item.get('description')):
continue
if _save_title(db, item) and kind == 'serie':
updated_series.add(title_id)
else:
raise SyncError('Catalogue pagination did not reach an empty terminal page')
series = [row[0] for row in db.execute("SELECT id,title,description FROM titles WHERE type='serie'")
if allowed_text(row[1]) and allowed_text(row[2])]
for index, series_id in enumerate(series, 1):
seasons = _objects(upstream.get(f'season/by/serie/{series_id}'))
if _save_seasons(db, series_id, seasons):
updated_series.add(series_id)
emit('series', index, len(series))
after = _counts(db)
completed = datetime.now(timezone.utc).isoformat()
report = {'status': 'ok', 'counts': after,
'added': {name: after[name] - before[name] for name in
('titles', 'movies', 'series', 'episodes', 'sources')},
'updated_series_ids': sorted(updated_series)[:100], 'updated_series_count': len(updated_series),
'media_files_downloaded': False, 'synchronized_at_utc': completed,
'upstream_base': UPSTREAM_BASE}
end_requests = getattr(upstream, 'requests_count', None)
if type(start_requests) is int and type(end_requests) is int:
report['requests_count'] = end_requests - start_requests
metadata.setdefault('original_snapshot_built_at_utc', metadata.get('built_at_utc'))
metadata.update({'built_at_utc': completed, 'synchronized_at_utc': completed,
'sync_interval_seconds': 259200, 'media_files_downloaded': False,
'counts': after, 'last_sync': report, 'catalog_pagination_complete': True,
'series_episode_fetch_complete': True})
db.executemany('INSERT INTO metadata VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value_json=excluded.value_json',
[(k, _encode(v)) for k, v in metadata.items()])
if db.execute('PRAGMA integrity_check').fetchall() != [('ok',)]:
raise SyncError('Candidate integrity check failed')
if db.execute('PRAGMA foreign_key_check').fetchall():
raise SyncError('Candidate foreign key check failed')
db.commit()
db.close()
db = None
emit('complete', 1, 1)
return report
except BaseException as error:
if db is not None:
db.close()
if created:
for suffix in ('', '-journal', '-wal', '-shm'):
Path(str(staged_db) + suffix).unlink(missing_ok=True)
if isinstance(error, (KeyboardInterrupt, SystemExit, FileExistsError, SyncError)):
raise
raise SyncError('Catalogue synchronization failed; candidate discarded') from None
+228
View File
@@ -0,0 +1,228 @@
"""Stage, validate and atomically publish read-only catalogue updates."""
import argparse
from contextlib import closing
from datetime import datetime, timezone
import fcntl
import json
import os
from pathlib import Path
import sqlite3
import stat
import subprocess
import time
import uuid
class UpdateError(Exception):
pass
def now():
return datetime.now(timezone.utc).isoformat()
def fsync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def atomic_json(path, value):
temp = path.with_name('.' + path.name + '.' + uuid.uuid4().hex)
try:
with temp.open('x', encoding='utf-8') as stream:
os.chmod(temp, 0o600)
json.dump(value, stream, ensure_ascii=False, indent=2)
stream.write('\n')
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
fsync_directory(path.parent)
finally:
temp.unlink(missing_ok=True)
def check_candidate(source, stage):
with closing(sqlite3.connect(source.resolve().as_uri() + '?mode=ro', uri=True)) as old:
with closing(sqlite3.connect(stage.resolve().as_uri() + '?mode=ro', uri=True)) as candidate:
if candidate.execute('PRAGMA quick_check').fetchone()[0] != 'ok':
raise UpdateError('Candidate integrity check failed')
if candidate.execute('PRAGMA foreign_key_check').fetchone() is not None:
raise UpdateError('Candidate relation check failed')
for table in ('titles', 'genres', 'countries', 'title_genres', 'title_countries',
'series_fetch_status', 'seasons', 'episodes',
'movie_sources', 'episode_sources'):
before = old.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
after = candidate.execute('SELECT COUNT(*) FROM ' + table).fetchone()[0]
if after < before:
raise UpdateError('Candidate would remove existing catalogue entries')
source_counts = (
'SELECT title_id, COUNT(*) FROM movie_sources GROUP BY title_id',
'''SELECT e.series_id, e.id,
CASE WHEN e.id IS NULL THEN e.season_index END,
CASE WHEN e.id IS NULL THEN e.episode_index END, COUNT(*)
FROM episodes AS e JOIN episode_sources AS s
USING (series_id, season_index, episode_index)
GROUP BY 1, 2, 3, 4''',
)
for query in source_counts:
after = {row[:-1]: row[-1] for row in candidate.execute(query)}
for row in old.execute(query):
if after.get(row[:-1], 0) < row[-1]:
raise UpdateError('Candidate would remove existing catalogue sources')
row = candidate.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Candidate timestamp missing')
return json.loads(row[0])
def run_update(database, state_dir, upstream, *, synchronize_fn=None,
restart_fn=None, health_fn=None, max_pages=2000, progress=None):
database, state_dir = Path(database).resolve(), Path(state_dir).resolve()
state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
if synchronize_fn is None:
from catalog_sync import synchronize
synchronize_fn = synchronize
restart_fn = restart_fn or restart_api
health_fn = health_fn or wait_for_api
with (state_dir / 'sync.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise UpdateError('A catalogue update is already running') from None
original = database.stat()
identity = (original.st_ino, original.st_size, original.st_mtime_ns)
stage = database.with_name('.catalog-sync-' + uuid.uuid4().hex + '.sqlite')
backup = database.with_name('catalog.previous.sqlite')
backup_temp = database.with_name('.catalog-backup-' + uuid.uuid4().hex)
published = False
started = now()
atomic_json(state_dir / 'last-result.json', {'status': 'running', 'started_at_utc': started})
try:
with closing(sqlite3.connect(database.as_uri() + '?mode=ro', uri=True)) as old:
row = old.execute("SELECT value_json FROM metadata WHERE key='built_at_utc'").fetchone()
if row is None or not isinstance(json.loads(row[0]), str):
raise UpdateError('Previous catalogue timestamp missing')
previous_stamp = json.loads(row[0])
report = synchronize_fn(database, stage, upstream, max_pages=max_pages, progress=progress)
stamp = check_candidate(database, stage)
current = database.stat()
if identity != (current.st_ino, current.st_size, current.st_mtime_ns):
raise UpdateError('Live catalogue changed during synchronisation')
os.chown(stage, original.st_uid, original.st_gid)
os.chmod(stage, stat.S_IMODE(original.st_mode))
with stage.open('rb') as stream:
os.fsync(stream.fileno())
os.link(database, backup_temp)
os.replace(backup_temp, backup)
fsync_directory(database.parent)
os.replace(stage, database)
published = True
fsync_directory(database.parent)
restart_fn()
health_fn(stamp)
result = {**report, 'status': 'success', 'started_at_utc': started,
'completed_at_utc': now(), 'api_health_verified': True,
'media_files_downloaded': False}
atomic_json(state_dir / 'last-success.json', result)
atomic_json(state_dir / 'last-result.json', result)
return result
except Exception as error:
rollback_failed = False
if published:
try:
os.replace(backup, database)
fsync_directory(database.parent)
restart_fn()
health_fn(previous_stamp)
except Exception:
rollback_failed = True
atomic_json(state_dir / 'last-result.json', {
'status': 'failed', 'started_at_utc': started, 'completed_at_utc': now(),
'error_type': type(error).__name__, 'rollback_failed': rollback_failed,
'media_files_downloaded': False,
})
if rollback_failed:
raise UpdateError('Update failed and API restart needs operator attention') from None
raise UpdateError('Update failed; the previous catalogue was retained') from None
finally:
for suffix in ('', '-journal', '-wal', '-shm'):
Path(str(stage) + suffix).unlink(missing_ok=True)
backup_temp.unlink(missing_ok=True)
def restart_api():
subprocess.run(['/bin/systemctl', 'restart', 'reelbox-api.service'],
check=True, capture_output=True, timeout=120)
def wait_for_api(expected_stamp):
from urllib.request import ProxyHandler, build_opener
opener = build_opener(ProxyHandler({}))
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
with opener.open('http://127.0.0.1:18765/api/health', timeout=5) as response:
health = json.loads(response.read(65536))
if (health.get('status') == 'ok' and health.get('media_proxy') is False
and health.get('snapshot_built_at_utc') == expected_stamp):
return
except Exception:
pass
time.sleep(2)
raise UpdateError('Updated API did not become healthy in time')
def emit_progress(event):
fields = {key: event[key] for key in ('phase', 'total') if key in event}
if 'completed' in event or 'count' in event:
fields['completed'] = event.get('completed', event.get('count'))
print(json.dumps({'progress': fields}), flush=True)
def probe(upstream):
genres = upstream.get('genre/all')
movies = upstream.get('movie/by/filtres/0/created/0')
series = upstream.get('serie/by/filtres/0/created/0')
if not all(isinstance(rows, list) and rows for rows in (genres, movies, series)):
raise UpdateError('Upstream metadata probe returned an unexpected shape')
movie_id = movies[0]['id']
series_id = series[0]['id']
detail = upstream.get('movie/by/' + str(movie_id))
seasons = upstream.get('season/by/serie/' + str(series_id))
if not isinstance(detail, dict) or not isinstance(seasons, list):
raise UpdateError('Upstream detail or season probe failed')
return {'status': 'probe_ok', 'movie_id': movie_id, 'series_id': series_id,
'movie_sources': len(detail.get('sources', [])), 'seasons': len(seasons),
'media_files_downloaded': False}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--database', type=Path, default=Path('/var/lib/reelbox-api/catalog.sqlite'))
parser.add_argument('--state-dir', type=Path, default=Path('/var/lib/reelbox-sync'))
parser.add_argument('--interval', type=float, default=0.25)
parser.add_argument('--max-pages', type=int, default=2000)
parser.add_argument('--probe', action='store_true')
args = parser.parse_args()
from catalog_sync import Upstream
key = os.environ.get('REELBOX_UPSTREAM_KEY', '')
if not key:
print('REELBOX_UPSTREAM_KEY is required; no requests sent', flush=True)
return 2
try:
upstream = Upstream(key, interval=max(0.25, args.interval))
result = probe(upstream) if args.probe else run_update(
args.database, args.state_dir, upstream, max_pages=args.max_pages, progress=emit_progress)
print(json.dumps(result, ensure_ascii=False), flush=True)
return 0
except Exception as error:
print(json.dumps({'status': 'failed', 'error_type': type(error).__name__,
'message': str(error) if isinstance(error, UpdateError) else 'Metadata update failed; see safe status record'}), flush=True)
return 1
if __name__ == '__main__':
raise SystemExit(main())
+68
View File
@@ -0,0 +1,68 @@
{
"format_version": 1,
"built_at_utc": "2026-11-02T07:00:18.228980+00:00",
"provenance": "API snapshot reconstructed from the read-only endpoints used by the downloaded Simba apps; not an original server-side database dump.",
"api_base": "https://ninwinios.info/api/",
"discovery_url": "http://ninnies-server.info/api-user/newapi/config-server186.txt",
"download_page": "https://zil.ink/movieboxapp",
"counts": {
"titles": 29931,
"genres": 50,
"countries": 84,
"movie_sources": 53853,
"seasons": 19132,
"episodes": 348897,
"episode_sources": 441945,
"movie": 20435,
"serie": 9496,
"series_seasons_fetched": 9496,
"series_seasons_pending": 0,
"movie_titles_without_sources": 8
},
"catalog_coverage": {
"movie": {
"kind": "movie",
"first_empty_page": 682,
"pages": 682,
"records": 20435,
"unique_ids": 20435,
"last_nonempty_page_count": 5
},
"serie": {
"kind": "serie",
"first_empty_page": 317,
"pages": 317,
"records": 9496,
"unique_ids": 9496,
"last_nonempty_page_count": 16
}
},
"catalog_pagination_complete": true,
"series_episode_fetch_complete": true,
"media_files_downloaded": false,
"media_urls_playback_checked": false,
"notes": [
"API pagination was exhausted, but the service is live and does not offer transactional snapshots or independent total counts.",
"Titles and media URLs are preserved as returned; dead URLs and catalog omissions may exist.",
"No accounts, private profiles, user data, comments or payment data were exported.",
"Extraction does not establish rights to redistribute metadata or stream the referenced videos."
],
"artifacts": {
"catalog.sqlite": {
"bytes": 598843392,
"sha256": "45213410c51eeee984a02883823e312272bd6c6a44ec75452e9f90f74db1a9d8"
},
"catalog.jsonl.gz": {
"bytes": 13425106,
"sha256": "a3ef9cba408e554e4b18cd4f8ededb2d69010972a02d7f0182f15fedc669eeae"
},
"seasons.jsonl.gz": {
"bytes": 6530014,
"sha256": "c1dc5e96a40f33587c6bcaf0dd1a3573d92a693c0756bbef11778ccc91ae6668"
},
"schema.sql": {
"bytes": 2375,
"sha256": "fd6deb8ed09540aeae8ebb00a621cbfefff19d4fd86e48d33edb28117e298066"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"download_page": "https://zil.ink/movieboxapp",
"inspection": "Static archive extraction; neither app was executed",
"binaries": [
{
"file": "Simba-v82.apk",
"url": "https://ninamacos.info/apk/Simba-v82.apk",
"bytes": 21415761,
"sha256": "089b742354ef868cdd1b49e0bb8f593f7c1c0df1e35d8a893da3fff1c05bea3c"
},
{
"file": "Simba-windows.zip",
"url": "https://ninamacos.info/apk/windows/Simba-Setup-1.3.5-x64.zip",
"bytes": 191886111,
"sha256": "e7904367e539f832b69991b067c489d4174b46a1918277e2bfc366e36f748754"
}
]
}
+23
View File
@@ -0,0 +1,23 @@
"""Install API routing on the existing Iran ReelBox HTTPS server."""
from datetime import datetime, timezone
from pathlib import Path
import shutil
import subprocess
config = Path('/etc/reelbox/nginx.conf')
original = config.read_text()
include = ' include /etc/reelbox/api-locations.conf;\n'
if include not in original:
anchor = ' root /var/www/reelbox/current;\n'
if original.count(anchor) != 1:
raise RuntimeError('Unexpected site configuration; refusing to alter it')
backup = config.with_name('nginx.conf.' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '.backup')
shutil.copy2(config, backup)
config.write_text(original.replace(anchor, include + anchor))
try:
subprocess.run(['/usr/sbin/nginx', '-t', '-c', str(config)], check=True)
except Exception:
config.write_text(original)
raise
subprocess.run(['systemctl', 'reload', 'reelbox-web'], check=True)
print('ReelBox metadata API routes enabled; website root and existing listeners unchanged')
+42
View File
@@ -0,0 +1,42 @@
# Include inside the ReelBox HTTPS server on 443 (legacy 8443 remains served).
# Preserve website/download routes and unrelated services; inspect each host.
location /api/ {
access_log off;
# Public metadata only; low compression level trades little CPU for fewer
# transferred bytes. Nginx handles negotiation, HEAD and chunked framing.
gzip on;
gzip_types application/json;
gzip_min_length 1024;
gzip_comp_level 2;
gzip_vary on;
gzip_proxied no-store;
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:18765;
proxy_http_version 1.0;
proxy_set_header Host localhost;
proxy_set_header Connection close;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_send_timeout 10s;
proxy_max_temp_file_size 0;
}
location /api-user/ {
access_log off;
# Public metadata only; low compression level trades little CPU for fewer
# transferred bytes. Nginx handles negotiation, HEAD and chunked framing.
gzip on;
gzip_types application/json;
gzip_min_length 1024;
gzip_comp_level 2;
gzip_vary on;
gzip_proxied no-store;
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:18765;
proxy_http_version 1.0;
proxy_set_header Host localhost;
proxy_set_header Connection close;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_send_timeout 10s;
proxy_max_temp_file_size 0;
}
+36
View File
@@ -0,0 +1,36 @@
[Unit]
Description=ReelBox read-only offline Catalogue API
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/reelbox-api
ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_api.py
Environment=REELBOX_CATALOG_DB=/var/lib/reelbox-api/catalog.sqlite
Environment=PYTHONUNBUFFERED=1
Restart=on-failure
RestartSec=3
TimeoutStartSec=120
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_INET AF_UNIX
IPAddressDeny=any
IPAddressAllow=localhost
MemoryMax=256M
TasksMax=24
LimitNOFILE=256
UMask=0077
[Install]
WantedBy=multi-user.target
+40
View File
@@ -0,0 +1,40 @@
[Unit]
Description=Refresh ReelBox catalogue metadata from the original API
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=1d
StartLimitBurst=3
[Service]
Type=oneshot
User=root
WorkingDirectory=/opt/reelbox-api
EnvironmentFile=/etc/reelbox/sync.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/usr/bin/python3 -B /opt/reelbox-api/catalog_sync_runner.py
TimeoutStartSec=8h
Restart=on-failure
RestartSec=1h
StateDirectory=reelbox-sync
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
ReadWritePaths=/var/lib/reelbox-api /var/lib/reelbox-sync
MemoryMax=512M
TasksMax=16
LimitNOFILE=512
Nice=10
IOSchedulingClass=idle
StandardOutput=journal
StandardError=journal
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Refresh ReelBox catalogue every three days
[Timer]
OnBootSec=15min
OnUnitActiveSec=3d
AccuracySec=1min
Unit=reelbox-sync.service
[Install]
WantedBy=timers.target
+37
View File
@@ -0,0 +1,37 @@
PRAGMA foreign_keys=ON;
CREATE TABLE metadata(key TEXT PRIMARY KEY,value_json TEXT NOT NULL);
CREATE TABLE titles(
id INTEGER PRIMARY KEY,type TEXT NOT NULL,title TEXT,description TEXT,
year INTEGER,imdb REAL,rating REAL,duration TEXT,classification TEXT,
image TEXT,cover TEXT,downloadas TEXT,playas TEXT,comments_enabled INTEGER,
raw_json TEXT NOT NULL);
CREATE INDEX titles_type ON titles(type);
CREATE INDEX titles_title ON titles(title COLLATE NOCASE);
CREATE INDEX titles_year ON titles(year);
CREATE TABLE genres(id INTEGER PRIMARY KEY,title TEXT);
CREATE TABLE countries(id INTEGER PRIMARY KEY,title TEXT,image TEXT);
CREATE TABLE title_genres(title_id INTEGER REFERENCES titles(id),genre_id INTEGER REFERENCES genres(id),PRIMARY KEY(title_id,genre_id));
CREATE TABLE title_countries(title_id INTEGER REFERENCES titles(id),country_id INTEGER REFERENCES countries(id),PRIMARY KEY(title_id,country_id));
CREATE TABLE movie_sources(
title_id INTEGER NOT NULL REFERENCES titles(id),source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(title_id,source_index));
CREATE INDEX movie_sources_title ON movie_sources(title_id);
CREATE TABLE series_fetch_status(
series_id INTEGER PRIMARY KEY REFERENCES titles(id),status TEXT NOT NULL,
season_count INTEGER,source_url TEXT NOT NULL);
CREATE TABLE seasons(
series_id INTEGER NOT NULL REFERENCES titles(id),season_index INTEGER NOT NULL,
id INTEGER,title TEXT,raw_json TEXT NOT NULL,PRIMARY KEY(series_id,season_index));
CREATE TABLE episodes(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,
id INTEGER,title TEXT,description TEXT,duration TEXT,downloadas TEXT,playas TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index),
FOREIGN KEY(series_id,season_index) REFERENCES seasons(series_id,season_index));
CREATE TABLE episode_sources(
series_id INTEGER NOT NULL,season_index INTEGER NOT NULL,episode_index INTEGER NOT NULL,source_index INTEGER NOT NULL,
source_id INTEGER,quality TEXT,media_type TEXT,url TEXT,raw_json TEXT NOT NULL,
PRIMARY KEY(series_id,season_index,episode_index,source_index),
FOREIGN KEY(series_id,season_index,episode_index) REFERENCES episodes(series_id,season_index,episode_index));
CREATE INDEX episode_sources_series ON episode_sources(series_id);
+98
View File
@@ -0,0 +1,98 @@
import json
import os
from pathlib import Path
import tempfile
import unittest
from test_catalog_api import fixture, load_service
class BannerTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
def tearDown(self):
self.catalog.close()
self.temp.cleanup()
def test_local_config_reloads_safe_bounded_ads_without_admin_endpoint(self):
config = Path(self.temp.name) / 'banners.json'
self.catalog.close()
self.catalog = self.api.Catalog(self.path, banner_config=config)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
ad = {'title_en': 'Fixture sponsor', 'title_fa': 'حامی آزمایشی',
'subtitle_en': 'Test only', 'target_url': 'https://sponsor.example/offer',
'image_url': 'https://sponsor.example/banner.jpg', 'secret': 'not public'}
config.write_text(json.dumps({'banners': [ad]}))
result = self.catalog.dispatch('/api/banners/')['banners']
self.assertEqual(len(result), 1)
self.assertEqual(result[0]['title_fa'], ad['title_fa'])
self.assertEqual(result[0]['image_url'], ad['image_url'])
self.assertNotIn('secret', result[0])
config.write_text('{invalid')
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for target in ['http://evil.example/', 'javascript:alert(1)', '//evil.example/',
'https://a:b@evil.example/', 'https://evil.example:444/',
'https://evil.example/ bad', 'https://evil.example/\\\\bad',
'https://evil.example/%0abad', 'https://',
'https://-bad.example/', 'https://bad..example/', 'https://bad.example./',
'https://evil.example/%zz', 'https://evil.example/' + 'x' * 2048]:
config.write_text(json.dumps({'banners': [{**ad, 'target_url': target}, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, target)
for row in [None, True, 5, {}, {**ad, 'title_en': 'x' * 81},
{**ad, 'subtitle_en': 'x' * 181}, {**ad, 'title_fa': 'bad\u202etext'},
{**ad, 'image_url': 'http://evil.example/banner.jpg'}]:
config.write_text(json.dumps({'banners': [row, ad]}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 1, row)
config.write_text(json.dumps({'banners': [ad] * 30}))
self.assertEqual(len(self.catalog.dispatch('/api/banners/')['banners']), 5)
config.write_text(json.dumps({'banners': [{}] * 20 + [ad]}))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
config.write_text(' ' * 65537)
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for value in [None, [], {'banners': {}}, {'banners': []}]:
config.write_text(json.dumps(value))
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
for route in ['/api/banners/admin/', '/api/banners/config/']:
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch(route)
self.assertEqual(error.exception.status, 404)
def test_http_banner_endpoint_is_json_no_store_and_rejects_writes(self):
from http.client import HTTPConnection
import threading
server = self.api.make_server(self.catalog, port=0)
worker = threading.Thread(target=server.serve_forever, daemon=True)
worker.start()
try:
for method, expected in [('GET', 200), ('HEAD', 200), ('POST', 405), ('PUT', 405)]:
client = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
client.request(method, '/api/banners/')
response = client.getresponse()
self.assertEqual(response.status, expected)
self.assertEqual(response.getheader('Cache-Control'), 'no-store')
self.assertIn('application/json', response.getheader('Content-Type'))
body = response.read()
if method == 'GET':
self.assertEqual(json.loads(body), {'banners': []})
if method == 'HEAD':
self.assertEqual(body, b'')
client.close()
finally:
server.shutdown()
server.server_close()
worker.join(timeout=5)
def test_read_only_banner_route_defaults_to_empty_ad_list(self):
self.assertEqual(self.catalog.dispatch('/api/banners/'), {'banners': []})
self.assertEqual(self.catalog.dispatch('/api/banners'), {'banners': []})
with self.assertRaises(self.api.ApiError) as error:
self.catalog.dispatch('/api/banners/?url=https://evil.example/')
self.assertEqual(error.exception.status, 400)
if __name__ == '__main__':
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
"""Regression coverage for app advertisements disguised as movie records."""
import json
import sqlite3
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
class CatalogueAdTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_app_promotions_and_typographic_variants_are_rejected(self):
self.open()
for text in [
'نورتون', 'نُوْرتـو\u200cن', 'N O R T O N',
'آموزش زبان به سبک موسوی‌باکس',
'آموزش زبان به سبک مووی باکس',
'آموزش زبان به سبک مُووی‌ باكْس',
'نسخه جدید هورتون', 'نسخه‌ جدید هُورتـون',
'اپلیکیشن هورتون', 'نرم افزار هورتون',
]:
with self.subTest(text=text):
self.assertFalse(self.api.allowed_text(text))
for text in [
'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون',
'آموزش زبان New Headway', 'The Marriage App',
'فیلمی درباره یک اپلیکیشن خیالی', 'بازیگران: Alex Horton',
]:
with self.subTest(text=text):
self.assertTrue(self.api.allowed_text(text))
def test_ads_are_absent_from_every_title_surface_and_direct_lookup(self):
with sqlite3.connect(self.path) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(66, 'نورتون', ''),
(67, 'آموزش زبان به سبک مووی باکس', ''),
(68, 'آموزش زبان به سبک موسوی‌باکس', ''),
(69, 'نسخه جدید هورتون', ''),
(70, 'Horton Hears a Who!', 'نام پارسی انیمیشن: هورتون'),
(71, 'آموزش زبان New Headway', 'مجموعه ویدیویی آموزش مهارت مکالمه'),
(72, 'The Marriage App', 'فیلمی درباره یک اپلیکیشن خیالی'),
(73, 'Language companion', 'اپلیکیشن هورتون'),
]
for title_id, title, description in records:
raw = {**original, 'id': title_id, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(title_id, 'movie', title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (title_id,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (title_id,))
catalog = self.open()
ads = {66, 67, 68, 69, 73}
actual = set()
for page in range(4):
for order in ['created', 'imdb', 'rating', 'year', 'title', 'random']:
for path in [f'/api/movie/by/filtres/0/{order}/{page}/public/',
f'/api/poster/by/filtres/2/1/{order}/{page}/public/']:
rows = catalog.dispatch(path)
self.assertFalse(ads & {row['id'] for row in rows}, path)
actual.update(row['id'] for row in rows)
self.assertTrue({70, 71, 72} <= actual)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(ads & {row['id'] for row in group['posters']})
for action in ['best-rand-top', 'best-rand-year']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action}))
self.assertFalse(ads & {row['id'] for row in rows})
for query in ['نورتون', 'هورتون', 'آموزش زبان', 'موسوی', 'مووی', 'Language companion']:
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': 'search-movie', 'q': query}))
self.assertFalse(ads & {row['id'] for row in rows}, query)
rows = catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')
self.assertFalse(ads & {row['id'] for row in rows})
for title_id in ads:
for path in [f'/api/movie/by/{title_id}/public/', f'/api/season/by/serie/{title_id}/public/']:
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
for title_id in [70, 71, 72]:
self.assertEqual(catalog.dispatch(f'/api/movie/by/{title_id}/public/')['id'], title_id)
detail = catalog.dispatch('/api/movie/by/1/public/')
self.assertEqual(detail['sources'], original['sources'])
+147
View File
@@ -0,0 +1,147 @@
"""Upstream notices are not catalogue films, including after later scrapes."""
from contextlib import closing
import json
from pathlib import Path
import sqlite3
import tempfile
from typing import Any
import unittest
from urllib.parse import urlencode
import test_catalog_api as fixtures
from test_catalog_api import load_service
from test_catalog_sync import FakeUpstream, load_sync
class AnnouncementPolicyTests(unittest.TestCase):
def test_generic_notice_labels_are_blocked_without_hiding_real_notice_films(self):
api = load_service()
for text in [
'اطلاعیه جدید', 'اطلاعیه مهم ', 'اطلاعیه خیلی مهم',
'📢 **اطلاعـیّهٔ جدید**', 'اطلاع يه جديد', 'اطلاع‌رسانی جدید',
'اعلان جدید', 'اعلان مهم شماره ۲',
'به روز رسانی جدید', 'به‌روزرسانی برنامه', 'بروز رسانی نسخه ویندوز',
'آپدیت جدید برنامه', 'New announcement', 'IMPORTANT NOTICE!',
'Urgent announcement: service changes', 'App update',
]:
with self.subTest(blocked=text):
self.assertFalse(api.allowed_text(text))
for text in [
'Red Notice', 'SAS: Red Notice', 'Burn Notice', 'Notice to Quit',
'Two Weeks Notice', 'The Announcement', 'اعلان قرمز', 'Upgrade',
'News of the World', 'نام پارسی فیلم: اعلان قرمز',
'A detective reads an important notice before leaving town.',
'در این فیلم یک اطلاعیه جدید، زندگی شخصیت اصلی را تغییر می‌دهد.',
]:
with self.subTest(allowed=text):
self.assertTrue(api.allowed_text(text))
class AnnouncementSurfaceTests(unittest.TestCase):
path: Path
temp: tempfile.TemporaryDirectory
api: Any
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def test_notices_are_absent_from_all_catalogue_routes_and_direct_lookup(self):
bad = {70, 71, 72, 73, 74}
with closing(sqlite3.connect(self.path)) as db:
original = json.loads(db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
records = [
(70, 'movie', 'اطلاعیه جدید', ''),
(71, 'movie', 'اطلاعیه خیلی مهم', ''),
(72, 'movie', 'به روز رسانی جدید', ''),
(73, 'movie', 'Service changes', 'Important announcement: app changes'),
(74, 'serie', 'اعلان جدید', ''),
(75, 'movie', 'Red Notice', 'نام پارسی فیلم: اعلان قرمز'),
(76, 'serie', 'Burn Notice', 'An ordinary spy series'),
]
for ident, kind, title, description in records:
raw = {**original, 'id': ident, 'type': kind, 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(ident, kind, title, description, 2026, 9.9, 5, json.dumps(raw)))
db.execute('INSERT INTO title_genres VALUES (?,2)', (ident,))
db.execute('INSERT INTO title_countries VALUES (?,1)', (ident,))
db.commit()
catalog = self.open()
found = set()
for kind in ('movie', 'serie', 'poster'):
for order in ('created', 'updated', 'imdb', 'rating', 'year', 'title', 'random'):
for page in range(4):
for path in (f'/api/{kind}/by/filtres/0/{order}/{page}/public/',
f'/api/{kind}/by/filtres/2/1/{order}/{page}/public/'):
ids = {row['id'] for row in catalog.dispatch(path)}
self.assertFalse(bad & ids, path)
found.update(ids)
self.assertTrue({75, 76} <= found)
for group in catalog.dispatch('/api/first/public/')['genres']:
self.assertFalse(bad & {row['id'] for row in group['posters']})
for action in ('best-rand-top', 'best-rand-year'):
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/like.php?' + urlencode({'action': action}))})
for action in ('search-movie', 'search-movie-en'):
for query in ('اطلاعیه', 'به روز', 'announcement', 'Notice'):
rows = catalog.dispatch('/api-user/newapi/like.php?' + urlencode({'action': action, 'q': query}))
self.assertFalse(bad & {row['id'] for row in rows})
self.assertFalse(bad & {row['id'] for row in catalog.dispatch(
'/api-user/newapi/filter.php?action=pishnahad&garid=2&pageno=1')})
for ident in bad:
for path in (f'/api/movie/by/{ident}/public/', f'/api/season/by/serie/{ident}/public/'):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(path)
self.assertEqual(error.exception.status, 404)
self.assertEqual(catalog.dispatch('/api/movie/by/75/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/movie/by/76/public/')['title'], 'Burn Notice')
def test_repeated_scrapes_keep_old_and_new_notice_ids_out_of_the_api(self):
# Old raw rows stay for snapshot/source preservation, but are never served.
with closing(sqlite3.connect(self.path)) as db:
item = {'id': 90, 'type': 'movie', 'title': 'اطلاعیه مهم', 'description': ''}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(90, 'movie', item['title'], '', json.dumps(item)))
db.commit()
source_bytes = self.path.read_bytes()
source = self.path
sync = load_sync()
for run, offset in enumerate((0, 100), 1):
new_notice, series_notice, detail_notice = 91 + offset, 92 + offset, 93 + offset
rows = [
{'id': 90, 'type': 'movie', 'title': 'اطلاعیه جدید'},
{'id': new_notice, 'type': 'movie', 'title': '📢 اطلاعیه جدید'},
{'id': detail_notice, 'type': 'movie', 'title': 'Looks like a film'},
{'id': 94, 'type': 'movie', 'title': 'Red Notice'},
]
upstream = FakeUpstream({
'movie/by/filtres/0/created/0': rows,
f'movie/by/{detail_notice}': {'id': detail_notice, 'type': 'movie', 'title': 'به روز رسانی جدید'},
'movie/by/94': {'id': 94, 'type': 'movie', 'title': 'Red Notice', 'sources': []},
'serie/by/filtres/0/created/0': [
{'id': series_notice, 'type': 'serie', 'title': 'Important announcement'}],
})
stage = Path(self.temp.name) / f'scrape-{run}.sqlite'
sync.synchronize(source, stage, upstream)
self.assertNotIn(f'movie/by/{new_notice}', upstream.calls)
self.assertNotIn('movie/by/90', upstream.calls)
self.assertNotIn(f'season/by/serie/{series_notice}', upstream.calls)
with closing(sqlite3.connect(stage)) as db:
self.assertEqual(db.execute('SELECT COUNT(*) FROM titles WHERE id IN (?,?,?)',
(new_notice, series_notice, detail_notice)).fetchone()[0], 0)
self.assertEqual(db.execute('SELECT title FROM titles WHERE id=90').fetchone()[0], 'اطلاعیه مهم')
api = load_service()
catalog = api.Catalog(stage)
try:
for ident in (90, new_notice, series_notice, detail_notice):
with self.assertRaises(api.ApiError):
catalog.dispatch(f'/api/movie/by/{ident}/public/')
self.assertEqual(catalog.dispatch('/api/movie/by/94/public/')['title'], 'Red Notice')
self.assertEqual(catalog.dispatch('/api/season/by/serie/2/public/')[0]['episodes'][0]['id'], 501)
finally:
catalog.close()
source = stage
self.assertEqual(self.path.read_bytes(), source_bytes)
if __name__ == '__main__':
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
import importlib.util
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import unittest
SERVER = Path(__file__).resolve().parents[1]
def load_service():
spec = importlib.util.spec_from_file_location('catalog_api', SERVER / 'catalog_api.py')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def fixture(directory):
path = Path(directory) / 'catalog.sqlite'
con = sqlite3.connect(path)
con.executescript((SERVER / 'tests' / 'schema.sql').read_text())
metadata = {'built_at_utc': '2026-11-02T07:00:18+00:00',
'provenance': 'Realistic test snapshot',
'catalog_pagination_complete': True, 'series_episode_fetch_complete': True}
con.executemany('INSERT INTO metadata VALUES (?,?)',
[(k, json.dumps(v)) for k, v in metadata.items()])
con.executemany('INSERT INTO genres VALUES (?,?)', [(1, 'Horror'), (2, 'Action'), (32, 'Top 250'), (48, 'Live')])
con.execute('INSERT INTO countries VALUES (1,?,?)', ('America', 'http://art.test/a%2Fb.jpg?token=Ab+Cd'))
for i in range(1, 66):
kind = 'serie' if i == 2 else 'movie'
title = 'Alpha' if i == 1 else 'Warrior' if i == 2 else 'Film ' + str(i)
if i == 63:
title = 'نسخه جدید ملودی باکس'
if i == 64:
kind = 'channel'
if i == 65:
title = 'Telegram @channelname'
item = {'id': i, 'type': kind, 'title': title, 'description': 'Actor Example بازیگران',
'year': 2020 + i % 7, 'imdb': i / 10, 'rating': 4.1,
'image': 'http://art.test/a%2Fb.jpg?token=Ab+Cd',
'cover': 'https://art.test/a b.jpg', 'comment': True,
'genres': [{'id': 2, 'title': 'Action'}], 'country': [{'id': 1, 'title': 'America'}]}
if kind == 'movie':
source = {'id': 100 + i, 'type': 'mkv', 'quality': '720',
'url': 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment'}
item['sources'] = [source]
con.execute('INSERT INTO titles(id,type,title,description,year,imdb,rating,raw_json) VALUES (?,?,?,?,?,?,?,?)',
(i, kind, title, item['description'], item['year'], item['imdb'], item['rating'], json.dumps(item)))
if kind == 'movie':
con.execute('INSERT INTO movie_sources VALUES (?,?,?,?,?,?,?)',
(i, 0, source['id'], '720', 'mkv', source['url'], json.dumps(source)))
con.execute('INSERT INTO title_genres VALUES (?,2)', (i,))
con.execute('INSERT INTO title_countries VALUES (?,1)', (i,))
con.execute('INSERT INTO title_genres VALUES (1,32)')
episode = {'id': 501, 'title': 'Episode 1', 'sources': [{'id': 201, 'type': 'mkv',
'quality': None, 'url': 'http://media.test/a%20b.mkv?signature=%2f+Ab'}]}
season = {'id': 1001, 'title': 'Season 1', 'episodes': [episode]}
con.execute('INSERT INTO seasons VALUES (2,0,1001,?,?)', ('Season 1', json.dumps(season)))
con.execute('INSERT INTO episodes(series_id,season_index,episode_index,id,title,raw_json) VALUES (2,0,0,501,?,?)',
('Episode 1', json.dumps(episode)))
con.execute('INSERT INTO episode_sources VALUES (2,0,0,0,201,NULL,?,?,?)',
('mkv', episode['sources'][0]['url'], json.dumps(episode['sources'][0])))
con.execute('INSERT INTO series_fetch_status VALUES (2,?,1,?)', ('ok', 'https://old.test/api/season/2'))
con.commit()
con.close()
return path
class CatalogTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.path = fixture(self.temp.name)
def tearDown(self):
if hasattr(self, 'catalog'):
self.catalog.close()
self.temp.cleanup()
def open(self):
self.api = load_service()
self.catalog = self.api.Catalog(self.path)
return self.catalog
def test_health_is_real_snapshot_counts_and_freshness(self):
catalog = self.open()
health = catalog.dispatch('/api/health')
self.assertEqual(health['status'], 'ok')
self.assertEqual(health['counts']['titles'], 65)
self.assertEqual(health['counts']['episodes'], 1)
self.assertEqual(health['counts']['visible_titles'], 62)
self.assertEqual(health['snapshot_built_at_utc'], '2026-11-02T07:00:18+00:00')
self.assertEqual(health['provenance'], 'Realistic test snapshot')
self.assertFalse(health['media_proxy'])
self.assertTrue(health['offline'])
self.assertGreaterEqual(health['snapshot_age_seconds'], 0)
with self.assertRaises(sqlite3.OperationalError):
catalog.db.execute('DELETE FROM titles')
def test_catalog_filters_pages_and_original_media_links(self):
catalog = self.open()
first = catalog.dispatch('/api/movie/by/filtres/0/created/0/public/')
second = catalog.dispatch('/api/movie/by/filtres/0/created/1/public/')
self.assertEqual(len(first), 30)
self.assertEqual(len(second), 30)
self.assertFalse({r['id'] for r in first} & {r['id'] for r in second})
self.assertTrue(all(r['id'] <= 62 and r['type'] == 'movie' for r in first + second))
self.assertEqual([r['id'] for r in catalog.dispatch('/api/movie/by/filtres/0/created/2/public/')], [1])
self.assertEqual(catalog.dispatch('/api/movie/by/filtres/0/created/3/public/'), [])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/poster/by/filtres/32/1/imdb/0/public/')], [1])
self.assertEqual([r['id'] for r in catalog.dispatch('/api/serie/by/filtres/0/year/0/public/')], [2])
film = catalog.dispatch('/api/movie/by/1/public/')
original = json.loads(catalog.db.execute('SELECT raw_json FROM titles WHERE id=1').fetchone()[0])
self.assertEqual(film['sources'], original['sources'])
self.assertEqual(film['image'], original['image'])
self.assertNotIn('comment', film)
seasons = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(seasons[0]['episodes'][0]['sources'][0]['url'], 'http://media.test/a%20b.mkv?signature=%2f+Ab')
self.assertEqual(catalog.dispatch('/api/genre/all/public/')[0]['id'], 1)
self.assertNotIn(48, [r['id'] for r in catalog.dispatch('/api/genre/all/public/')])
self.assertEqual(catalog.dispatch('/api/country/all/public/')[0]['title'], 'America')
for title_id in (63, 64, 65, 999):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(f'/api/movie/by/{title_id}/public/')
self.assertEqual(error.exception.status, 404)
def test_home_search_recommendations_curated_and_unexported_metadata(self):
catalog = self.open()
home = catalog.dispatch('/api/first/public/')
self.assertTrue(home['genres'][0]['posters'])
self.assertEqual(home['actors'], [])
self.assertEqual(home['countries'][0]['id'], 1)
search = catalog.dispatch('/api-user/newapi/like.php?action=search-movie-en&q=alpha&pageno=1')
self.assertEqual([r['id'] for r in search], [1])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=no-match&pageno=1'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=%27%20OR%201%3D1--'), [])
self.assertEqual(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-top&pageno=1')[0]['id'], 62)
self.assertTrue(catalog.dispatch('/api-user/newapi/like.php?action=best-rand-year&pageno=1'))
curated = catalog.dispatch('/api-user/newapi/pishnahad.php')
self.assertTrue(curated)
self.assertEqual(len(curated[0]['title']), len(curated[0]['link']))
self.assertTrue(catalog.dispatch(curated[0]['link'][0] + '1'))
self.assertEqual([r['id'] for r in catalog.dispatch('/api-user/newapi/filter.php?action=pishnahad&garid=32&pageno=1')], [1])
for target in ['/api-user/newapi/like.php?action=all-actor&pageno=1',
'/api-user/newapi/like.php?action=colection&pageno=1',
'/api-user/filmahang/star.php?key=public&action=starsearch&q=Example&pageno=1',
'/api-user/filmahang/star.php?key=public&action=mazmon&pageno=1',
'/api-user/filmahang/star.php?key=public&action=share&pageno=1',
'/api/role/by/poster/1/public/', '/api/movie/by/actor/12/public/',
'/api/subtitles/by/movie/1/public/', '/api/subtitles/by/episode/501/public/']:
self.assertEqual(catalog.dispatch(target), [], target)
def test_rejects_proxy_comments_traversal_and_unbounded_inputs(self):
catalog = self.open()
for target in ['/api/proxy?url=http://169.254.169.254/', '/api/download/movie.mkv',
'/api/comments/by/poster/1/public/', '/api/../catalog.sqlite',
'/api/%2e%2e/catalog.sqlite', '//evil.test/api/health',
'/api/movie/by/1/public/?url=https://media.test/movie.mkv',
'/api-user/newapi/like.php?action=comments',
'/api-user/newapi/like.php?action=search-movie&q=a&q=b',
'/api-user/newapi/like.php?action=search-movie&q=' + 'a' * 201,
'/api-user/newapi/like.php?action=search-movie&pageno=-1',
'/api-user/newapi/like.php?action=search-movie&pageno=100002',
'/api/movie/by/filtres/0/views/0/public/']:
with self.assertRaises(self.api.ApiError, msg=target):
catalog.dispatch(target)
def test_raw_controls_and_malformed_urls_are_rejected_before_parsing(self):
catalog = self.open()
for target in ['/api/\thealth', '\n/api/health', '/api/health\r', 'http://[',
'/api-user/newapi/like.php?action=search-movie&q=a\tb']:
with self.subTest(target=repr(target)):
with self.assertRaises(self.api.ApiError) as error:
catalog.dispatch(target)
self.assertEqual(error.exception.status, 400)
def test_real_http_is_read_only_bounded_and_returns_direct_source_urls(self):
import threading
from urllib.request import Request, urlopen
from urllib.error import HTTPError
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
origin = 'http://127.0.0.1:' + str(server.server_port)
try:
with urlopen(origin + '/api/movie/by/1/public/', timeout=3) as response:
detail = json.load(response)
self.assertEqual(response.headers['Content-Type'], 'application/json; charset=utf-8')
self.assertNotIn('Server', response.headers)
self.assertEqual(detail['sources'][0]['url'], 'http://media.test/Movie%20Name%2F.mkv?token=Ab+Cd&x=%2f#fragment')
for method in ['POST', 'PUT', 'DELETE', 'PATCH', 'CONNECT']:
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/health', method=method), timeout=3)
self.assertEqual(error.exception.code, 405)
error.exception.close()
with self.assertRaises(HTTPError) as error:
urlopen(origin + '/api/proxy?url=https://media.test/a.mkv', timeout=3)
self.assertIn(error.exception.code, (400, 404))
error.exception.close()
with urlopen(Request(origin + '/api/health', method='HEAD'), timeout=3) as response:
self.assertEqual(response.status, 200)
self.assertEqual(response.read(), b'')
finally:
server.shutdown()
server.server_close()
thread.join(3)
def test_worker_saturation_rejects_extra_connections_and_releases_slots(self):
import socket
import threading
import time
catalog = self.open()
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
sockets = []
def wait_slots(value):
deadline = time.monotonic() + 4
while server.slots._value != value and time.monotonic() < deadline:
time.sleep(.01)
self.assertEqual(server.slots._value, value)
try:
for _ in range(16):
sock = socket.create_connection(server.server_address, timeout=2)
sock.sendall(b'GET /api/health HTTP/1.0\r\n')
sockets.append(sock)
wait_slots(0)
with socket.create_connection(server.server_address, timeout=2) as extra:
try:
self.assertEqual(extra.recv(1), b'')
except ConnectionResetError:
pass
for sock in sockets:
sock.close()
sockets.clear()
wait_slots(16)
with socket.create_connection(server.server_address, timeout=2) as recovered:
recovered.sendall(b'GET /api/health HTTP/1.0\r\n\r\n')
self.assertIn(b'200', recovered.recv(100))
finally:
for sock in sockets:
sock.close()
server.shutdown()
server.server_close()
thread.join(3)
if __name__ == '__main__':
unittest.main()
+110
View File
@@ -0,0 +1,110 @@
"""Operation-count regressions; timings are recorded separately, never asserted."""
import json
import threading
import unittest
from unittest.mock import patch
from urllib.request import Request, urlopen
from urllib.error import HTTPError
import test_catalog_api as fixtures
class CatalogPerformanceTests(unittest.TestCase):
setUp = fixtures.CatalogTests.setUp
tearDown = fixtures.CatalogTests.tearDown
open = fixtures.CatalogTests.open
def http_server(self, catalog):
server = self.api.make_server(catalog, port=0)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def stop():
server.shutdown()
server.server_close()
thread.join(3)
self.addCleanup(stop)
return 'http://127.0.0.1:' + str(server.server_port)
def test_repeated_snapshot_http_read_does_not_query_or_serialize_again(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
queries = []
catalog.db.set_trace_callback(queries.append)
with patch.object(self.api.json, 'dumps', wraps=json.dumps) as encode:
with urlopen(origin + '/api/first/public/', timeout=3) as response:
self.assertEqual(response.read(), first)
self.assertEqual(queries, [], 'Warm snapshot HTTP responses must reuse encoded bytes')
self.assertEqual(encode.call_count, 0)
def test_cache_bounds_lru_and_large_response_bypass(self):
catalog = self.open()
catalog.RESPONSE_CACHE_ENTRIES = 2
one = '/api/movie/by/1/public/'
two = '/api/movie/by/2/public/'
three = '/api/movie/by/3/public/'
first = catalog.response(one)
catalog.response(two)
self.assertIs(catalog.response(one), first)
catalog.response(three)
self.assertEqual(list(catalog._responses), [one, three])
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
catalog.RESPONSE_CACHE_ITEM_BYTES = 1
catalog.response(two)
self.assertNotIn(two, catalog._responses)
# Byte budget, not just number of entries, forces eviction.
catalog.RESPONSE_CACHE_ITEM_BYTES = 1024 * 1024
catalog.RESPONSE_CACHE_BYTES = len(catalog.response(three)) + len(three) + 1
catalog.response(two)
self.assertLessEqual(catalog._response_bytes, catalog.RESPONSE_CACHE_BYTES)
self.assertEqual(len(catalog._responses), 1)
def test_dynamic_responses_errors_and_queries_are_never_retained(self):
catalog = self.open()
with patch.object(catalog, 'health', side_effect=[{'age': 1}, {'age': 2}]):
self.assertNotEqual(catalog.response('/api/health'), catalog.response('/api/health'))
with patch.object(self.api, 'load_banners', side_effect=[{'banners': [1]}, {'banners': [2]}]):
self.assertNotEqual(catalog.response('/api/banners'), catalog.response('/api/banners'))
catalog.response('/api-user/newapi/like.php?action=search-movie&q=Alpha')
for _ in range(2):
with self.assertRaises(self.api.ApiError):
catalog.response('/api/movie/by/999/public/')
self.assertEqual(list(catalog._responses), [])
def test_warm_head_reuses_get_bytes_and_body_requests_still_fail(self):
catalog = self.open()
origin = self.http_server(catalog)
with urlopen(origin + '/api/first/public/', timeout=3) as response:
first = response.read()
with urlopen(Request(origin + '/api/first/public/', method='HEAD'), timeout=3) as response:
self.assertEqual(response.read(), b'')
self.assertEqual(int(response.headers['Content-Length']), len(first))
with self.assertRaises(HTTPError) as error:
urlopen(Request(origin + '/api/first/public/', method='GET', data=b'x'), timeout=3)
self.assertEqual(error.exception.code, 400)
error.exception.close()
def test_normalized_search_matches_fallback_with_unicode_and_pagination(self):
catalog = self.open()
indexed = catalog.search_text
self.assertLessEqual(catalog.search_index_bytes, 64 * 1024 * 1024)
for query in ['Actor', 'بازیگران', 'بازيگران', 'ALPHA', 'not-present', 'Film 4']:
for page in [0, 1, 2]:
catalog.search_text = indexed
expected = catalog.titles(query=query, page=page)
catalog.search_text = {}
self.assertEqual(catalog.titles(query=query, page=page), expected)
catalog.search_text = indexed
def test_search_normalizes_query_not_every_snapshot_row(self):
catalog = self.open()
with patch.object(self.api, 'normal', wraps=self.api.normal) as normalize:
catalog.db.create_function('normal', 1, normalize, deterministic=True)
result = catalog.dispatch('/api-user/newapi/like.php?action=search-movie&q=ACTOR&pageno=1')
self.assertEqual([row['id'] for row in result], list(range(62, 32, -1)))
self.assertLessEqual(normalize.call_count, 2,
'Immutable title text must not be normalized on every search')
if __name__ == '__main__':
unittest.main()
+463
View File
@@ -0,0 +1,463 @@
"""Synchronizer tests use the exported schema, never the upstream service."""
import copy
from contextlib import closing
import importlib.util
import io
import json
import os
import sys
from pathlib import Path
import sqlite3
import ssl
import tempfile
import unittest
from unittest.mock import patch
from urllib.error import HTTPError, URLError
from test_catalog_api import SERVER, fixture, load_service
def load_sync():
spec = importlib.util.spec_from_file_location('catalog_sync', SERVER / 'catalog_sync.py')
module = importlib.util.module_from_spec(spec)
with patch.object(sys, 'path', [str(SERVER)] + sys.path):
spec.loader.exec_module(module)
return module
class Response(io.BytesIO):
status = 200
headers = {}
def __init__(self, payload=b'[]', url=None, status=200):
super().__init__(payload)
self.url, self.status = url, status
def geturl(self):
return self.url
class QueueOpener:
def __init__(self, *responses):
self.responses = list(responses)
self.calls = []
def open(self, request, timeout):
self.calls.append((request.full_url, request.get_method(), timeout))
response = self.responses.pop(0)
if isinstance(response, Exception):
raise response
if response.url is None:
response.url = request.full_url
return response
class UpstreamTests(unittest.TestCase):
def test_exact_fixed_origin_metadata_urls_and_quoted_key(self):
sync = load_sync()
opener = QueueOpener(*(Response(b'{}' if p.startswith('movie/by/3') else b'[]') for p in
['genre/all', 'country/all', 'movie/by/35721',
'movie/by/filtres/0/created/0', 'serie/by/filtres/0/created/12',
'season/by/serie/35716']))
client = sync.Upstream('test secret/+#?', interval=0, opener=opener)
paths = ['genre/all', 'country/all', 'movie/by/35721', 'movie/by/filtres/0/created/0',
'serie/by/filtres/0/created/12', 'season/by/serie/35716']
for path in paths:
client.get(path)
self.assertEqual(opener.calls, [('https://ninwinios.info/api/' + p +
'/test%20secret%2F%2B%23%3F/', 'GET', 30) for p in paths])
self.assertEqual(client.requests_count, 6)
for bad in ['https://evil.test/api/genre/all', '//evil.test/a', '/genre/all',
'genre/all/', 'genre/../all', 'genre/%2e%2e/all', 'genre/all?url=x',
'genre/all#x', 'movie/by/1/surprise', 'download/movie.mkv',
'movie/by/1\n', 'movie/by/0', 'movie/by/-1', 'movie/by/filtres/0/views/0']:
with self.subTest(path=repr(bad)), self.assertRaises(ValueError):
client.get(bad)
self.assertEqual(client.requests_count, 6)
def test_real_upstream_legacy_html_label_still_requires_valid_json(self):
sync = load_sync()
response = Response(b'[{"id":1,"title":"Action"}]')
response.headers = {'Content-Type': 'text/html; charset=UTF-8'}
client = sync.Upstream('k', interval=0, opener=QueueOpener(response))
self.assertEqual(client.get('genre/all'), [{'id': 1, 'title': 'Action'}])
html = Response(b'<html>Service unavailable</html>')
html.headers = {'Content-Type': 'text/html; charset=UTF-8'}
opener = QueueOpener(html)
with self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_wrong_content_type_and_truncated_length_are_not_retried(self):
sync = load_sync()
for headers in ({'Content-Type': 'video/mp4'}, {'Content-Type': 'image/jpeg'},
{'Content-Length': '999'}, {'Content-Length': 'bad'},
{'Content-Length': str(32 * 1024 * 1024 + 1)}):
response = Response(b'[]')
response.headers = headers
opener = QueueOpener(response)
with self.subTest(headers=headers), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
def test_bounded_retry_and_rate_limit(self):
sync = load_sync()
for status in (429, 503):
error = HTTPError('https://secret.test/key', status, 'private key', {}, None)
opener = QueueOpener(error, Response(b'[]'))
client = sync.Upstream('private-key', interval=.25, opener=opener)
with patch.object(sync.time, 'sleep') as sleep:
self.assertEqual(client.get('genre/all'), [])
self.assertGreaterEqual(sleep.call_count, 1)
self.assertEqual(client.requests_count, 2)
opener = QueueOpener(URLError('private URL'), Response(b'[]'))
with patch.object(sync.time, 'sleep'):
self.assertEqual(sync.Upstream('k', interval=0, opener=opener).get('genre/all'), [])
opener = QueueOpener(Response(), Response())
client = sync.Upstream('k', interval=.25, opener=opener)
with patch.object(sync.time, 'monotonic', return_value=100), patch.object(sync.time, 'sleep') as sleep:
client.get('genre/all')
client.get('country/all')
sleep.assert_called_once_with(.25)
def test_auth_cert_redirect_json_and_size_fail_closed_without_secrets(self):
sync = load_sync()
for failure in [HTTPError('https://secret.test/private-key', code, 'private-key', {}, None)
for code in (301, 302, 307, 308, 401, 403, 404)] + [
URLError(ssl.SSLCertVerificationError('private-key')),
ssl.SSLCertVerificationError('private-key')]:
opener = QueueOpener(failure)
client = sync.Upstream('private-key', interval=0, opener=opener)
with self.assertRaises(sync.SyncError) as error:
client.get('genre/all')
self.assertNotIn('private-key', str(error.exception))
self.assertNotIn('https:', str(error.exception))
self.assertEqual(len(opener.calls), 1)
for response in [Response(b'not JSON'), Response(b'NaN'), Response(b'{}'),
Response(b'null'), Response(b'[]', url='https://evil.test/video.mkv'),
Response(b'[]', status=302), Response(b'[' * (32 * 1024 * 1024 + 1))]:
opener = QueueOpener(response)
with self.assertRaises(sync.SyncError):
sync.Upstream('private-key', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 1)
client = sync.Upstream('k', interval=0)
from urllib.request import HTTPRedirectHandler
handlers = [h for h in client._opener.handlers if isinstance(h, HTTPRedirectHandler)]
self.assertEqual(len(handlers), 1)
with self.assertRaises(sync.SyncError):
handlers[0].redirect_request(None, None, 302, 'moved', {}, 'https://evil.test/video')
def test_retry_exhaustion_and_configuration_are_bounded(self):
sync = load_sync()
opener = QueueOpener(*(HTTPError('https://secret/key', 503, 'key', {}, None) for _ in range(3)))
with patch.object(sync.time, 'sleep'), self.assertRaises(sync.SyncError):
sync.Upstream('k', interval=0, opener=opener).get('genre/all')
self.assertEqual(len(opener.calls), 3)
for kwargs in ({'attempts': 0}, {'attempts': 11}, {'interval': -1}, {'timeout': 0},
{'interval': float('nan')}, {'timeout': float('inf')}):
with self.subTest(kwargs=kwargs), self.assertRaises(ValueError):
sync.Upstream('k', **kwargs)
class FakeUpstream:
def __init__(self, routes=None):
self.routes = {'genre/all': [], 'country/all': [],
'movie/by/filtres/0/created/0': [],
'movie/by/filtres/0/created/1': [],
'serie/by/filtres/0/created/0': [],
'serie/by/filtres/0/created/1': [], 'season/by/serie/2': []}
self.routes.update(routes or {})
self.calls = []
self.requests_count = 0
def get(self, path):
self.calls.append(path)
self.requests_count += 1
if path not in self.routes:
raise AssertionError('Unexpected metadata path: ' + path)
value = self.routes[path]
if isinstance(value, Exception):
raise value
return copy.deepcopy(value)
class SynchronizeTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(dir=os.environ.get('TMPDIR'))
self.source = fixture(self.temp.name)
self.stage = Path(self.temp.name) / 'stage.sqlite'
self.sync = load_sync()
def tearDown(self):
self.temp.cleanup()
def rows(self, path, sql, args=()):
with closing(sqlite3.connect(path)) as db:
return db.execute(sql, args).fetchall()
def metadata(self, path):
return {key: json.loads(value) for key, value in self.rows(path, 'SELECT * FROM metadata')}
def test_all_known_visible_series_refresh_and_series_samples_are_bounded(self):
with closing(sqlite3.connect(self.source)) as db:
for title_id, title, description in [(90, 'An older series', 'Normal description'),
(91, 'Telegram @channelname', ''),
(92, 'Disguised advertisement', 'ملودی باکس')]:
item = {'id': title_id, 'type': 'serie', 'title': title, 'description': description}
db.execute('INSERT INTO titles(id,type,title,description,raw_json) VALUES (?,?,?,?,?)',
(title_id, 'serie', title, description, json.dumps(item)))
db.commit()
upstream = FakeUpstream({'season/by/serie/90': [{'id': 900, 'title': 'Season 1', 'episodes': [
{'id': 901, 'title': 'New episode on an old series'}]}]})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['episodes'], 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertIn('season/by/serie/90', upstream.calls)
self.assertNotIn('season/by/serie/91', upstream.calls)
self.assertNotIn('season/by/serie/92', upstream.calls)
self.assertEqual(report['counts']['series'], 4)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles WHERE id IN (91,92)'), [(2,)])
self.stage.unlink()
records = [{'id': i, 'type': 'serie', 'title': 'New Series ' + str(i)} for i in range(100, 201)]
routes = {'serie/by/filtres/0/created/0': records, 'season/by/serie/90': []}
routes.update({f'season/by/serie/{item["id"]}': [] for item in records})
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['updated_series_count'], 101)
self.assertEqual(len(report['updated_series_ids']), 100)
self.assertEqual(report['updated_series_ids'], list(range(100, 200)))
def test_progress_failure_discards_candidate_and_missing_source_is_not_created(self):
original = self.source.read_bytes()
def reject(_event):
raise RuntimeError('private-key')
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(), progress=reject)
self.assertNotIn('private-key', str(error.exception))
self.assertEqual(self.source.read_bytes(), original)
self.assertFalse(self.stage.exists())
missing = Path(self.temp.name) / 'missing.sqlite'
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(missing, self.stage, FakeUpstream())
self.assertFalse(missing.exists())
self.assertFalse(self.stage.exists())
def test_malformed_metadata_discards_candidate_not_source(self):
original = self.source.read_bytes()
cases = [
{'genre/all': {'error': 'not a list'}},
{'country/all': [{'error': 'bad metadata'}]},
{'movie/by/filtres/0/created/0': {'error': 'failed'}},
{'serie/by/filtres/0/created/0': [None]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie'}]},
{'serie/by/filtres/0/created/0': [{'id': 70, 'type': 'serie', 'title': {'bad': 'title'}}]},
{'movie/by/filtres/0/created/0': [{'id': True, 'type': 'movie', 'title': 'Bad'}]},
{'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha', 'sources': None}]},
{'season/by/serie/2': {'error': 'not seasons'}},
{'season/by/serie/2': [{'error': 'not a season'}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': {'error': 'not episodes'}}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'error': 'not an episode'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': [{'id': 201, 'url': []}]}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501, 'sources': 'not sources'}]}]},
{'season/by/serie/2': [{'id': 1001, 'episodes': [{'id': 501}, {'id': 501}]}]},
{'season/by/serie/2': ValueError('https://private.test/private-key')},
]
for routes in cases:
with self.subTest(routes=routes):
with self.assertRaises(self.sync.SyncError) as error:
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertNotIn('private-key', str(error.exception))
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_429_503_exhaustion_rolls_back_staged_writes(self):
original = self.source.read_bytes()
for status in (429, 503):
opener = QueueOpener(Response(b'[{"id":99,"title":"A new genre"}]'), Response(),
*(HTTPError('https://private.test/private-key', status, 'private-key', {}, None)
for _ in range(3)))
upstream = self.sync.Upstream('private-key', interval=0, opener=opener)
with patch.object(self.sync.time, 'sleep'), self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(len(opener.calls), 5)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
def test_page_limit_and_existing_candidates_are_never_published_or_overwritten(self):
original = self.source.read_bytes()
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated'}]}
with self.assertRaises(self.sync.SyncError):
self.sync.synchronize(self.source, self.stage, FakeUpstream(routes), max_pages=1)
self.assertFalse(self.stage.exists())
self.assertEqual(self.source.read_bytes(), original)
self.stage.write_bytes(b'existing candidate')
with self.assertRaises(FileExistsError):
self.sync.synchronize(self.source, self.stage, FakeUpstream())
self.assertEqual(self.stage.read_bytes(), b'existing candidate')
with self.assertRaises(ValueError):
self.sync.synchronize(self.source, self.source, FakeUpstream())
self.assertEqual(self.source.read_bytes(), original)
def test_existing_id_does_not_end_pagination_and_missing_ids_use_indices(self):
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Alpha'}],
'movie/by/filtres/0/created/1': [{'id': 70, 'type': 'movie', 'title': 'Next page'}],
'movie/by/filtres/0/created/2': [],
'movie/by/70': {'id': 70, 'type': 'movie', 'title': 'Next page', 'sources': []},
'season/by/serie/2': [{'title': 'Season 1 refreshed', 'episodes': [
{'title': 'Episode 1 refreshed', 'sources': []}, {'title': 'Episode without ID', 'sources': []}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['episodes'], 1)
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM seasons'), [(1001, 'Season 1 refreshed')])
self.assertEqual(self.rows(self.stage, 'SELECT id,title FROM episodes ORDER BY episode_index'),
[(501, 'Episode 1 refreshed'), (None, 'Episode without ID')])
self.assertEqual(self.rows(self.stage, 'SELECT source_id FROM episode_sources'), [(201,)])
def test_advertisements_channels_and_detail_ads_are_not_imported(self):
items = [
{'id': 70, 'type': 'movie', 'title': 'Telegram @channelname'},
{'id': 71, 'type': 'movie', 'title': 'Innocent', 'description': 'نسخه جدید ملودی باکس'},
{'id': 72, 'type': 'channel', 'title': 'Live channel'},
{'id': 73, 'type': 'movie', 'title': 'Looks like a movie'}]
upstream = FakeUpstream({'movie/by/filtres/0/created/0': items,
'movie/by/filtres/0/created/1': [],
'movie/by/73': {'id': 73, 'type': 'movie', 'title': 'Telegram @channelname'}})
report = self.sync.synchronize(self.source, self.stage, upstream)
self.assertEqual(report['added']['titles'], 0)
self.assertNotIn('movie/by/70', upstream.calls)
self.assertNotIn('movie/by/71', upstream.calls)
self.assertNotIn('movie/by/72', upstream.calls)
self.assertEqual(self.rows(self.stage, 'SELECT COUNT(*) FROM titles'), [(65,)])
def test_reordered_stable_ids_and_truncated_nested_lists_retain_every_old_entry(self):
first = {'season/by/serie/2': [
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/old2'}]}]},
{'id': 1002, 'title': 'Season 2', 'episodes': [{'id': 601, 'title': 'S2 E1'}]}]}
self.sync.synchronize(self.source, self.stage, FakeUpstream(first))
second = Path(self.temp.name) / 'second.sqlite'
routes = {'movie/by/filtres/0/created/0': [{'id': 1, 'type': 'movie', 'title': 'Updated Alpha',
'sources': [], 'genres': []}],
'movie/by/filtres/0/created/1': [],
'season/by/serie/2': [
{'id': 1002, 'title': 'Season Two', 'episodes': []},
{'id': 1001, 'episodes': [{'id': 502, 'title': 'Updated Episode 2', 'sources': [
{'id': 202, 'url': 'https://video.test/refreshed'}]},
{'id': 501, 'sources': []}]}]}
report = self.sync.synchronize(self.stage, second, FakeUpstream(routes))
self.assertEqual(report['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(self.rows(second, 'SELECT season_index,id FROM seasons ORDER BY season_index'),
[(0, 1001), (1, 1002)])
self.assertEqual(self.rows(second, 'SELECT season_index,episode_index,id FROM episodes ORDER BY 1,2'),
[(0, 0, 501), (0, 1, 502), (1, 0, 601)])
self.assertEqual(self.rows(second, 'SELECT url FROM episode_sources WHERE source_id=202'),
[('https://video.test/refreshed',)])
for table in ('seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertGreaterEqual(self.rows(second, 'SELECT COUNT(*) FROM ' + table)[0][0],
self.rows(self.stage, 'SELECT COUNT(*) FROM ' + table)[0][0])
third = Path(self.temp.name) / 'third.sqlite'
self.sync.synchronize(second, third, FakeUpstream())
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(second, 'SELECT * FROM ' + table), self.rows(third, 'SELECT * FROM ' + table))
season = json.loads(self.rows(second, 'SELECT raw_json FROM seasons WHERE id=1001')[0][0])
self.assertEqual([e['id'] for e in season['episodes']], [501, 502])
self.assertEqual(season['episodes'][1]['sources'][0]['url'], 'https://video.test/refreshed')
movie = json.loads(self.rows(second, 'SELECT raw_json FROM titles WHERE id=1')[0][0])
self.assertTrue(movie['sources'])
self.assertTrue(movie['genres'])
def test_old_series_absent_from_catalogue_and_new_series_receive_episodes_idempotently(self):
new_series = {'id': 70, 'type': 'serie', 'title': 'New Series'}
old_episode = {'id': 501, 'title': 'Episode 1', 'sources': []}
new_episode = {'id': 502, 'title': 'Episode 2', 'sources': [
{'id': 202, 'quality': '1080', 'type': 'mkv', 'url': 'https://video.test/e2.mkv'}]}
routes = {'serie/by/filtres/0/created/0': [new_series],
'serie/by/filtres/0/created/1': [],
'season/by/serie/2': [{'id': 1001, 'title': 'Season 1', 'episodes': [old_episode, new_episode]}],
'season/by/serie/70': [{'id': 1070, 'title': 'New Season', 'episodes': [
{'id': 701, 'title': 'Pilot', 'sources': [
{'id': 702, 'url': 'https://video.test/pilot.mp4', 'type': 'mp4'}]}]}]}
report = self.sync.synchronize(self.source, self.stage, FakeUpstream(routes))
self.assertEqual(report['added'], {'titles': 1, 'movies': 0, 'series': 1, 'episodes': 2, 'sources': 2})
self.assertEqual(report['updated_series_ids'], [2, 70])
self.assertEqual(report['updated_series_count'], 2)
self.assertEqual(self.rows(self.stage, 'SELECT id FROM episodes ORDER BY id'), [(501,), (502,), (701,)])
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM seasons WHERE series_id=2')[0][0])
self.assertEqual(len(raw['episodes']), 2)
self.assertEqual(raw['episodes'][0]['sources'][0]['id'], 201)
self.assertEqual(raw['episodes'][1], new_episode)
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/season/by/serie/2/public/')
self.assertEqual(served[0]['episodes'][1], new_episode)
finally:
catalog.close()
second_stage = Path(self.temp.name) / 'second.sqlite'
again = self.sync.synchronize(self.stage, second_stage, FakeUpstream(routes))
self.assertEqual(again['added'], dict.fromkeys(report['added'], 0))
self.assertEqual(again['updated_series_ids'], [])
for table in ('titles', 'seasons', 'episodes', 'movie_sources', 'episode_sources'):
self.assertEqual(self.rows(self.stage, 'SELECT * FROM ' + table),
self.rows(second_stage, 'SELECT * FROM ' + table))
self.assertEqual(self.metadata(second_stage)['original_snapshot_built_at_utc'],
self.metadata(self.source)['built_at_utc'])
for (url,) in self.rows(second_stage, 'SELECT source_url FROM series_fetch_status'):
self.assertTrue(url.startswith('https://ninwinios.info/api/season/by/serie/'))
self.assertNotIn('key', url)
def test_new_movie_details_staged_source_untouched_and_real_counts(self):
original = self.source.read_bytes()
movie = {'id': 70, 'type': 'movie', 'title': 'New Film', 'year': 2026,
'genres': [{'id': 5, 'title': 'Drama'}], 'country': [{'id': 3, 'title': 'Iran'}],
'image': 'https://images.test/never-fetch.jpg'}
detail = dict(movie, description='New description', sources=[
{'id': 700, 'type': 'mkv', 'url': 'https://video.test/never-fetch.mkv?key=opaque'}])
upstream = FakeUpstream({'genre/all': [{'id': 5, 'title': 'Drama'}],
'country/all': [{'id': 3, 'title': 'Iran'}],
'movie/by/filtres/0/created/0': [movie, movie],
'movie/by/filtres/0/created/1': [], 'movie/by/70': detail})
progress = []
report = self.sync.synchronize(self.source, self.stage, upstream, progress=progress.append)
self.assertEqual(self.source.read_bytes(), original)
self.assertEqual(report['added']['titles'], 1)
self.assertEqual(report['added']['movies'], 1)
self.assertEqual(report['added']['series'], 0)
self.assertEqual(report['added']['sources'], 1)
self.assertEqual(report['counts']['titles'], 66)
self.assertEqual(report['counts']['episodes'], 1)
self.assertEqual(report['requests_count'], upstream.requests_count)
self.assertFalse(report['media_files_downloaded'])
self.assertEqual(upstream.calls.count('movie/by/70'), 1)
self.assertIn('season/by/serie/2', upstream.calls)
self.assertTrue(all('http' not in path and '.mkv' not in path and '.jpg' not in path
for path in upstream.calls))
raw = json.loads(self.rows(self.stage, 'SELECT raw_json FROM titles WHERE id=70')[0][0])
self.assertEqual(raw, detail)
self.assertEqual(self.rows(self.stage, 'SELECT url FROM movie_sources WHERE title_id=70')[0][0],
detail['sources'][0]['url'])
catalog = load_service().Catalog(self.stage)
try:
served = catalog.dispatch('/api/movie/by/70/public/')
self.assertEqual(served, detail)
finally:
catalog.close()
self.assertEqual(self.rows(self.stage, 'PRAGMA integrity_check'), [('ok',)])
self.assertEqual(self.rows(self.stage, 'PRAGMA foreign_key_check'), [])
metadata = self.metadata(self.stage)
self.assertEqual(metadata['original_snapshot_built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertNotEqual(metadata['built_at_utc'], self.metadata(self.source)['built_at_utc'])
self.assertEqual(metadata['built_at_utc'], metadata['synchronized_at_utc'])
self.assertEqual(metadata['sync_interval_seconds'], 259200)
self.assertEqual(metadata['counts']['titles'], 66)
self.assertFalse(metadata['media_files_downloaded'])
self.assertEqual(metadata['last_sync']['upstream_base'], 'https://ninwinios.info/api/')
self.assertEqual(json.loads(json.dumps(report)), report)
self.assertTrue(progress)
for item in progress:
self.assertLessEqual(set(item), {'phase', 'count', 'total'})
self.assertIsInstance(item['phase'], str)
self.assertIsInstance(item['count'], int)
if __name__ == '__main__':
unittest.main()
Loaded 100 of 445 files, more files were not shown because too many files have changed in this diff. Show more