Update
This commit is contained in:
commit
0895bc6bf6
971 changed files with 81599 additions and 0 deletions
18
.editorconfig
Normal file
18
.editorconfig
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
insert_final_newline = true
|
||||||
|
max_line_length = 140
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[{*.kts,*.kt}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = tab
|
||||||
|
tab_width = 4
|
||||||
|
# Disable wildcard imports in IntelliJ/Android Studio
|
||||||
|
ij_kotlin_name_count_to_use_star_import = 1000
|
||||||
|
ij_kotlin_name_count_to_use_star_import_for_members = 1000
|
||||||
|
ij_kotlin_packages_to_use_import_on_demand = unset
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
7
.gitattributes
vendored
Normal file
7
.gitattributes
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
*.kt text eol=lf
|
||||||
|
*.kts text eol=lf
|
||||||
|
*.java text eol=lf
|
||||||
|
*.xml text eol=lf
|
||||||
|
|
||||||
|
CONTRIBUTORS.md merge=union
|
||||||
|
app/src/main/res/values*/strings.xml merge=union
|
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
/.idea
|
||||||
|
.gradle
|
||||||
|
.kotlin/
|
||||||
|
build/
|
||||||
|
captures/
|
339
LICENSE
Normal file
339
LICENSE
Normal file
|
@ -0,0 +1,339 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
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
|
||||||
|
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 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 2 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, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision 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, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This 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.
|
24
android-lint.xml
Normal file
24
android-lint.xml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<lint>
|
||||||
|
<issue id="BinaryOperationInTimber" severity="error" />
|
||||||
|
<issue id="ConvertToWebp" severity="warning" />
|
||||||
|
<issue id="DuplicateStrings" severity="ignore" />
|
||||||
|
<issue id="EasterEgg" severity="fatal" />
|
||||||
|
<issue id="ExtraTranslation" severity="warning" />
|
||||||
|
<issue id="FragmentTagUsage" severity="error" />
|
||||||
|
<issue id="GradleDependency" severity="ignore" />
|
||||||
|
<issue id="KotlinPropertyAccess" severity="warning" />
|
||||||
|
<issue id="LambdaLast" severity="warning" />
|
||||||
|
<issue id="LogNotTimber" severity="error" />
|
||||||
|
<issue id="MangledCRLF" severity="warning" />
|
||||||
|
<issue id="NegativeMargin" severity="warning" />
|
||||||
|
<issue id="NoHardKeywords" severity="warning" />
|
||||||
|
<issue id="NotificationPermission">
|
||||||
|
<ignore regexp="com.bumptech.glide.request.target.NotificationTarget" />
|
||||||
|
</issue>
|
||||||
|
<issue id="Registered" severity="warning" />
|
||||||
|
<issue id="RequiredSize" severity="error" />
|
||||||
|
<issue id="Typos" severity="ignore" />
|
||||||
|
<issue id="UnknownNullness" severity="informational" />
|
||||||
|
<issue id="UnusedIds" severity="ignore" />
|
||||||
|
</lint>
|
166
app/build.gradle.kts
Normal file
166
app/build.gradle.kts
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
kotlin("android")
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.aboutlibraries)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.jellyfin.androidtv"
|
||||||
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
|
|
||||||
|
// Release version
|
||||||
|
applicationId = "org.askartv.tv"
|
||||||
|
versionName = "0.3.0"
|
||||||
|
versionCode = 16
|
||||||
|
setProperty("archivesBaseName", "alaskartv-androidtv-v0.3.0")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
viewBinding = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
val release by getting {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
|
||||||
|
// Set package names used in various XML files
|
||||||
|
resValue("string", "app_id", namespace!!)
|
||||||
|
resValue("string", "app_search_suggest_authority", "${namespace}.content")
|
||||||
|
resValue("string", "app_search_suggest_intent_data", "content://${namespace}.content/intent")
|
||||||
|
|
||||||
|
// Set flavored application name
|
||||||
|
resValue("string", "app_name", "@string/app_name_release")
|
||||||
|
|
||||||
|
buildConfigField("boolean", "DEVELOPMENT", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
val debug by getting {
|
||||||
|
// Use different application id to run release and debug at the same time
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
|
||||||
|
// Set package names used in various XML files
|
||||||
|
resValue("string", "app_id", namespace + applicationIdSuffix)
|
||||||
|
resValue("string", "app_search_suggest_authority", "${namespace + applicationIdSuffix}.content")
|
||||||
|
resValue("string", "app_search_suggest_intent_data", "content://${namespace + applicationIdSuffix}.content/intent")
|
||||||
|
|
||||||
|
// Set flavored application name
|
||||||
|
resValue("string", "app_name", "@string/app_name_debug")
|
||||||
|
|
||||||
|
buildConfigField("boolean", "DEVELOPMENT", (defaultConfig.versionCode!! < 100).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
lintConfig = file("$rootDir/android-lint.xml")
|
||||||
|
abortOnError = false
|
||||||
|
sarifReport = true
|
||||||
|
checkDependencies = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions.unitTests.all {
|
||||||
|
it.useJUnitPlatform()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutLibraries {
|
||||||
|
// Remove the "generated" timestamp to allow for reproducible builds
|
||||||
|
excludeFields = arrayOf("generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionTxt by tasks.registering {
|
||||||
|
val path = layout.buildDirectory.asFile.get().resolve("version.txt")
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
|
||||||
|
logger.info("Writing [$versionString] to $path")
|
||||||
|
path.writeText("$versionString\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Jellyfin
|
||||||
|
implementation(projects.playback.core)
|
||||||
|
implementation(projects.playback.jellyfin)
|
||||||
|
implementation(projects.playback.media3.exoplayer)
|
||||||
|
implementation(projects.playback.media3.session)
|
||||||
|
implementation(projects.preference)
|
||||||
|
implementation(libs.jellyfin.apiclient)
|
||||||
|
implementation(libs.jellyfin.sdk) {
|
||||||
|
// Change version if desired
|
||||||
|
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||||
|
when (sdkVersion) {
|
||||||
|
"local" -> version { strictly("latest-SNAPSHOT") }
|
||||||
|
"snapshot" -> version { strictly("master-SNAPSHOT") }
|
||||||
|
"unstable-snapshot" -> version { strictly("openapi-unstable-SNAPSHOT") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kotlin
|
||||||
|
implementation(libs.kotlinx.coroutines)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
// Android(x)
|
||||||
|
implementation(libs.androidx.core)
|
||||||
|
implementation(libs.androidx.activity)
|
||||||
|
implementation(libs.androidx.fragment)
|
||||||
|
implementation(libs.androidx.fragment.compose)
|
||||||
|
implementation(libs.androidx.leanback.core)
|
||||||
|
implementation(libs.androidx.leanback.preference)
|
||||||
|
implementation(libs.androidx.preference)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
implementation(libs.androidx.tvprovider)
|
||||||
|
implementation(libs.androidx.constraintlayout)
|
||||||
|
implementation(libs.androidx.recyclerview)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
|
implementation(libs.bundles.androidx.lifecycle)
|
||||||
|
implementation(libs.androidx.window)
|
||||||
|
implementation(libs.androidx.cardview)
|
||||||
|
implementation(libs.androidx.startup)
|
||||||
|
implementation(libs.bundles.androidx.compose)
|
||||||
|
implementation(libs.androidx.tv.material)
|
||||||
|
|
||||||
|
// Dependency Injection
|
||||||
|
implementation(libs.bundles.koin)
|
||||||
|
|
||||||
|
// Media players
|
||||||
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
|
implementation(libs.androidx.media3.exoplayer.hls)
|
||||||
|
implementation(libs.androidx.media3.ui)
|
||||||
|
implementation(libs.jellyfin.androidx.media3.ffmpeg.decoder)
|
||||||
|
|
||||||
|
// Markdown
|
||||||
|
implementation(libs.bundles.markwon)
|
||||||
|
|
||||||
|
// Image utility
|
||||||
|
implementation(libs.bundles.coil)
|
||||||
|
|
||||||
|
// Crash Reporting
|
||||||
|
implementation(libs.bundles.acra)
|
||||||
|
|
||||||
|
// Licenses
|
||||||
|
implementation(libs.aboutlibraries)
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
implementation(libs.timber)
|
||||||
|
implementation(libs.slf4j.timber)
|
||||||
|
|
||||||
|
// Compatibility (desugaring)
|
||||||
|
coreLibraryDesugaring(libs.android.desugar)
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
testImplementation(libs.kotest.runner.junit5)
|
||||||
|
testImplementation(libs.kotest.assertions)
|
||||||
|
testImplementation(libs.mockk)
|
||||||
|
}
|
7
app/src/debug/res/values/logo.xml
Normal file
7
app/src/debug/res/values/logo.xml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="logo_gradient_start">#F2364D</color>
|
||||||
|
<color name="logo_gradient_stop">#FDC92F</color>
|
||||||
|
<color name="logo_background">#0A0A0A</color>
|
||||||
|
<color name="logo_text">#FFFFFF</color>
|
||||||
|
</resources>
|
146
app/src/main/AndroidManifest.xml
Normal file
146
app/src/main/AndroidManifest.xml
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:installLocation="auto">
|
||||||
|
|
||||||
|
<!-- Android TV Integration -->
|
||||||
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
|
||||||
|
|
||||||
|
<!-- Generic permissions -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.ACCESS_WIFI_STATE"
|
||||||
|
tools:ignore="LeanbackUsesWifi" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
|
||||||
|
<!-- Search permissions (voice to text) -->
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<!-- Device feature requirements -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.microphone"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data
|
||||||
|
android:host="youtube.com"
|
||||||
|
android:scheme="https" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".JellyfinApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:appCategory="video"
|
||||||
|
android:banner="@drawable/app_banner"
|
||||||
|
android:dataExtractionRules="@xml/backup_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_content"
|
||||||
|
android:icon="@mipmap/app_icon"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:largeHeap="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Jellyfin"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
|
|
||||||
|
<!-- Legacy service required for AccountManagerMigration -->
|
||||||
|
<service
|
||||||
|
android:name=".auth.service.AuthenticatorService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Screensaver -->
|
||||||
|
<service
|
||||||
|
android:name=".integration.dream.LibraryDreamService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.dreams.DreamService" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
android:initOrder="1000"
|
||||||
|
tools:node="merge">
|
||||||
|
<meta-data
|
||||||
|
android:name="org.jellyfin.androidtv.LogInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
<meta-data
|
||||||
|
android:name="org.jellyfin.androidtv.di.KoinInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
<meta-data
|
||||||
|
android:name="org.jellyfin.androidtv.SessionInitializer"
|
||||||
|
android:value="androidx.startup" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".integration.MediaContentProvider"
|
||||||
|
android:authorities="${applicationId}.content"
|
||||||
|
android:exported="true"
|
||||||
|
android:initOrder="10" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name=".integration.provider.ImageProvider"
|
||||||
|
android:authorities="${applicationId}.integration.provider.ImageProvider"
|
||||||
|
android:exported="true"
|
||||||
|
android:initOrder="10" />
|
||||||
|
|
||||||
|
<!-- Authentication -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.startup.StartupActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:noHistory="true"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:windowSoftInputMode="adjustNothing">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<action android:name="android.intent.action.SEARCH" />
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.searchable"
|
||||||
|
android:resource="@xml/searchable" />
|
||||||
|
</activity>
|
||||||
|
<activity-alias
|
||||||
|
android:name=".startup.StartupActivity"
|
||||||
|
android:targetActivity=".ui.startup.StartupActivity" />
|
||||||
|
|
||||||
|
<!-- Main application -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.browsing.MainActivity"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:screenOrientation="landscape"
|
||||||
|
android:windowSoftInputMode="adjustNothing" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".ui.preference.PreferencesActivity"
|
||||||
|
android:screenOrientation="behind"
|
||||||
|
android:theme="@style/Theme.Jellyfin.Preferences" />
|
||||||
|
|
||||||
|
<activity android:name=".ui.playback.ExternalPlayerActivity" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
|
@ -0,0 +1,64 @@
|
||||||
|
package org.jellyfin.androidtv
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.await
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.acra.ACRA
|
||||||
|
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||||
|
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||||
|
import org.jellyfin.androidtv.integration.LeanbackChannelWorker
|
||||||
|
import org.jellyfin.androidtv.telemetry.TelemetryService
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class JellyfinApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// Don't run in ACRA service
|
||||||
|
if (ACRA.isACRASenderServiceProcess()) return
|
||||||
|
|
||||||
|
val notificationsRepository by inject<NotificationsRepository>()
|
||||||
|
notificationsRepository.addDefaultNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from the StartupActivity when the user session is started.
|
||||||
|
*/
|
||||||
|
suspend fun onSessionStart() = withContext(Dispatchers.IO) {
|
||||||
|
val workManager by inject<WorkManager>()
|
||||||
|
val socketListener by inject<SocketHandler>()
|
||||||
|
|
||||||
|
// Update background worker
|
||||||
|
launch {
|
||||||
|
// Cancel all current workers
|
||||||
|
workManager.cancelAllWork().await()
|
||||||
|
|
||||||
|
// Recreate periodic workers
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.UPDATE,
|
||||||
|
PeriodicWorkRequestBuilder<LeanbackChannelWorker>(1, TimeUnit.HOURS)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.LINEAR, 10, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update WebSockets
|
||||||
|
launch { socketListener.updateSession() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun attachBaseContext(base: Context?) {
|
||||||
|
super.attachBaseContext(base)
|
||||||
|
|
||||||
|
TelemetryService.init(this)
|
||||||
|
}
|
||||||
|
}
|
28
app/src/main/java/org/jellyfin/androidtv/LogInitializer.kt
Normal file
28
app/src/main/java/org/jellyfin/androidtv/LogInitializer.kt
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
package org.jellyfin.androidtv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.startup.Initializer
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class LogInitializer : Initializer<Unit> {
|
||||||
|
override fun create(context: Context) {
|
||||||
|
// Enable improved logging for leaking resources
|
||||||
|
// https://wh0.github.io/2020/08/12/closeguard.html
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
try {
|
||||||
|
Class.forName("dalvik.system.CloseGuard")
|
||||||
|
.getMethod("setEnabled", Boolean::class.javaPrimitiveType)
|
||||||
|
.invoke(null, true)
|
||||||
|
} catch (e: ReflectiveOperationException) {
|
||||||
|
@Suppress("TooGenericExceptionThrown")
|
||||||
|
throw RuntimeException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the logging library
|
||||||
|
Timber.plant(Timber.DebugTree())
|
||||||
|
Timber.i("Debug tree planted")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package org.jellyfin.androidtv
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.startup.AppInitializer
|
||||||
|
import androidx.startup.Initializer
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||||
|
import org.jellyfin.androidtv.di.KoinInitializer
|
||||||
|
|
||||||
|
@Suppress("unused")
|
||||||
|
class SessionInitializer : Initializer<Unit> {
|
||||||
|
override fun create(context: Context) {
|
||||||
|
val koin = AppInitializer.getInstance(context)
|
||||||
|
.initializeComponent(KoinInitializer::class.java)
|
||||||
|
.koin
|
||||||
|
|
||||||
|
// Restore system session
|
||||||
|
ProcessLifecycleOwner.get().lifecycleScope.launch {
|
||||||
|
koin.get<SessionRepository>().restoreSession(destroyOnly = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dependencies() = listOf(KoinInitializer::class.java)
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.jellyfin.androidtv.auth
|
||||||
|
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class AccountManagerMigration(
|
||||||
|
context: Context,
|
||||||
|
) {
|
||||||
|
private val accountManager = requireNotNull(context.getSystemService<AccountManager>())
|
||||||
|
|
||||||
|
fun migrate(
|
||||||
|
servers: Map<UUID, AuthenticationStoreServer>,
|
||||||
|
) = servers.mapValues { (serverId, server) ->
|
||||||
|
Timber.i("Migrating server $serverId (${server.name})")
|
||||||
|
server.copy(
|
||||||
|
users = server.users.mapValues { (userId, user) ->
|
||||||
|
val accessToken = getAccessToken(serverId, userId)
|
||||||
|
Timber.i("Migrating user $userId (${user.name}): ${if (accessToken != null) "success" else "no token"}")
|
||||||
|
user.copy(accessToken = accessToken)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MissingPermission")
|
||||||
|
private fun getAccessToken(serverId: UUID, userId: UUID): String? = runCatching {
|
||||||
|
accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||||
|
.firstOrNull {
|
||||||
|
val validServerId = accountManager.getUserData(it, ACCOUNT_DATA_SERVER)?.toUUIDOrNull() == serverId
|
||||||
|
val validUserId = accountManager.getUserData(it, ACCOUNT_DATA_ID)?.toUUIDOrNull() == userId
|
||||||
|
|
||||||
|
validServerId && validUserId
|
||||||
|
}
|
||||||
|
?.let { account -> accountManager.peekAuthToken(account, ACCOUNT_ACCESS_TOKEN_TYPE) }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACCOUNT_TYPE = BuildConfig.APPLICATION_ID
|
||||||
|
const val ACCOUNT_DATA_ID = "$ACCOUNT_TYPE.id"
|
||||||
|
const val ACCOUNT_DATA_SERVER = "$ACCOUNT_TYPE.server"
|
||||||
|
const val ACCOUNT_ACCESS_TOKEN_TYPE = "$ACCOUNT_TYPE.access_token"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.jellyfin.androidtv.auth.apiclient
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.auth.repository.Session
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.jellyfin.androidtv.util.sdk.legacy
|
||||||
|
import org.jellyfin.apiclient.interaction.ApiClient
|
||||||
|
import org.jellyfin.apiclient.model.apiclient.ServerInfo
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
class ApiBinder(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val authenticationStore: AuthenticationStore,
|
||||||
|
) {
|
||||||
|
fun updateSession(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||||
|
@Suppress("TooGenericExceptionCaught")
|
||||||
|
val success = try {
|
||||||
|
updateSessionInternal(session, deviceInfo)
|
||||||
|
} catch (throwable: Throwable) {
|
||||||
|
Timber.e(throwable, "Unable to update legacy API session.")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSessionInternal(session: Session?, deviceInfo: DeviceInfo): Boolean {
|
||||||
|
if (session == null) return true
|
||||||
|
|
||||||
|
val server = authenticationStore.getServer(session.serverId)
|
||||||
|
if (server == null) {
|
||||||
|
Timber.e("Could not bind API because server ${session.serverId} was not found in the store.")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
api.device = deviceInfo.legacy()
|
||||||
|
api.SetAuthenticationInfo(session.accessToken, session.userId.toString())
|
||||||
|
api.EnableAutomaticNetworking(ServerInfo().apply {
|
||||||
|
id = session.serverId.toString()
|
||||||
|
name = server.name
|
||||||
|
address = server.address.removeSuffix("/")
|
||||||
|
userId = session.userId.toString()
|
||||||
|
accessToken = session.accessToken
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
sealed class AuthenticateMethod
|
||||||
|
data class AutomaticAuthenticateMethod(val user: User) : AuthenticateMethod()
|
||||||
|
data class CredentialAuthenticateMethod(val username: String, val password: String = "") : AuthenticateMethod()
|
||||||
|
data class QuickConnectAuthenticateMethod(val secret: String) : AuthenticateMethod()
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class AuthenticationSortBy(
|
||||||
|
override val nameRes: Int
|
||||||
|
) : PreferenceEnum {
|
||||||
|
LAST_USE(R.string.last_use),
|
||||||
|
ALPHABETICAL(R.string.alphabetical);
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
@file:UseSerializers(UUIDSerializer::class)
|
||||||
|
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.UseSerializers
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locally stored server information. New properties require default values or deserialization will fail.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AuthenticationStoreServer(
|
||||||
|
val name: String,
|
||||||
|
val address: String,
|
||||||
|
val version: String? = null,
|
||||||
|
@SerialName("login_disclaimer") val loginDisclaimer: String? = null,
|
||||||
|
@SerialName("splashscreen_enabled") val splashscreenEnabled: Boolean = false,
|
||||||
|
@SerialName("setup_completed") val setupCompleted: Boolean = true,
|
||||||
|
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||||
|
@SerialName("last_refreshed") val lastRefreshed: Long = Instant.now().toEpochMilli(),
|
||||||
|
val users: Map<UUID, AuthenticationStoreUser> = emptyMap(),
|
||||||
|
)
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locally stored user information. New properties require default values or deserialization will fail.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AuthenticationStoreUser(
|
||||||
|
val name: String,
|
||||||
|
@SerialName("last_used") val lastUsed: Long = Instant.now().toEpochMilli(),
|
||||||
|
@SerialName("image_tag") val imageTag: String? = null,
|
||||||
|
@SerialName("access_token") val accessToken: String? = null,
|
||||||
|
)
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
|
||||||
|
sealed class LoginState
|
||||||
|
data object AuthenticatingState : LoginState()
|
||||||
|
data object RequireSignInState : LoginState()
|
||||||
|
data object ServerUnavailableState : LoginState()
|
||||||
|
data class ServerVersionNotSupported(val server: Server) : LoginState()
|
||||||
|
data class ApiClientErrorLoginState(val error: ApiClientException) : LoginState()
|
||||||
|
data object AuthenticatedState : LoginState()
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
sealed class QuickConnectState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State unknown until first poll completed.
|
||||||
|
*/
|
||||||
|
data object UnknownQuickConnectState : QuickConnectState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server does not have QuickConnect enabled.
|
||||||
|
*/
|
||||||
|
data object UnavailableQuickConnectState : QuickConnectState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection is pending.
|
||||||
|
*/
|
||||||
|
data class PendingQuickConnectState(val code: String) : QuickConnectState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User connected.
|
||||||
|
*/
|
||||||
|
data object ConnectedQuickConnectState : QuickConnectState()
|
|
@ -0,0 +1,35 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||||
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server model to use locally in place of ServerInfo model in ApiClient.
|
||||||
|
*/
|
||||||
|
data class Server(
|
||||||
|
var id: UUID,
|
||||||
|
var name: String,
|
||||||
|
var address: String,
|
||||||
|
val version: String? = null,
|
||||||
|
val loginDisclaimer: String? = null,
|
||||||
|
val splashscreenEnabled: Boolean = false,
|
||||||
|
val setupCompleted: Boolean = true,
|
||||||
|
var dateLastAccessed: Instant = Instant.MIN,
|
||||||
|
) {
|
||||||
|
private val serverVersion = version?.let(ServerVersion::fromString)
|
||||||
|
val versionSupported = serverVersion != null && serverVersion >= ServerRepository.minimumServerVersion
|
||||||
|
|
||||||
|
operator fun compareTo(other: ServerVersion): Int = serverVersion?.compareTo(other) ?: -1
|
||||||
|
|
||||||
|
override fun equals(other: Any?) = other is Server
|
||||||
|
&& id == other.id
|
||||||
|
&& address == other.address
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = id.hashCode()
|
||||||
|
result = 31 * result + address.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerIssue
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
sealed class ServerAdditionState
|
||||||
|
data class ConnectingState(val address: String) : ServerAdditionState()
|
||||||
|
data class UnableToConnectState(val addressCandidates: Map<String, Collection<RecommendedServerIssue>>) : ServerAdditionState()
|
||||||
|
data class ConnectedState(val id: UUID, val publicInfo: PublicSystemInfo) : ServerAdditionState()
|
53
app/src/main/java/org/jellyfin/androidtv/auth/model/User.kt
Normal file
53
app/src/main/java/org/jellyfin/androidtv/auth/model/User.kt
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package org.jellyfin.androidtv.auth.model
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User model used locally.
|
||||||
|
*/
|
||||||
|
sealed class User {
|
||||||
|
abstract val id: UUID
|
||||||
|
abstract val serverId: UUID
|
||||||
|
abstract val name: String
|
||||||
|
abstract val accessToken: String?
|
||||||
|
abstract val imageTag: String?
|
||||||
|
|
||||||
|
abstract fun withToken(accessToken: String): User
|
||||||
|
|
||||||
|
override fun equals(other: Any?) = other is User
|
||||||
|
&& serverId == other.serverId
|
||||||
|
&& id == other.id
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = id.hashCode()
|
||||||
|
result = 31 * result + serverId.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user stored client side.
|
||||||
|
*/
|
||||||
|
data class PrivateUser(
|
||||||
|
override val id: UUID,
|
||||||
|
override val serverId: UUID,
|
||||||
|
override val name: String,
|
||||||
|
override val accessToken: String?,
|
||||||
|
override val imageTag: String?,
|
||||||
|
val lastUsed: Long,
|
||||||
|
) : User() {
|
||||||
|
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a user stored server side. Found using the Public User endpoint.
|
||||||
|
*/
|
||||||
|
data class PublicUser(
|
||||||
|
override val id: UUID,
|
||||||
|
override val serverId: UUID,
|
||||||
|
override val name: String,
|
||||||
|
override val accessToken: String?,
|
||||||
|
override val imageTag: String?,
|
||||||
|
) : User() {
|
||||||
|
override fun withToken(accessToken: String) = copy(accessToken = accessToken)
|
||||||
|
}
|
|
@ -0,0 +1,209 @@
|
||||||
|
package org.jellyfin.androidtv.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import org.jellyfin.androidtv.auth.model.ApiClientErrorLoginState
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticateMethod
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticatedState
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticatingState
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||||
|
import org.jellyfin.androidtv.auth.model.AutomaticAuthenticateMethod
|
||||||
|
import org.jellyfin.androidtv.auth.model.CredentialAuthenticateMethod
|
||||||
|
import org.jellyfin.androidtv.auth.model.LoginState
|
||||||
|
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||||
|
import org.jellyfin.androidtv.auth.model.QuickConnectAuthenticateMethod
|
||||||
|
import org.jellyfin.androidtv.auth.model.RequireSignInState
|
||||||
|
import org.jellyfin.androidtv.auth.model.Server
|
||||||
|
import org.jellyfin.androidtv.auth.model.ServerUnavailableState
|
||||||
|
import org.jellyfin.androidtv.auth.model.ServerVersionNotSupported
|
||||||
|
import org.jellyfin.androidtv.auth.model.User
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.jellyfin.androidtv.util.ImageHelper
|
||||||
|
import org.jellyfin.androidtv.util.sdk.forUser
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.authenticateWithQuickConnect
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to manage authentication of the user in the app.
|
||||||
|
*/
|
||||||
|
interface AuthenticationRepository {
|
||||||
|
fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState>
|
||||||
|
fun logout(user: User): Boolean
|
||||||
|
fun getUserImageUrl(server: Server, user: User): String?
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthenticationRepositoryImpl(
|
||||||
|
private val jellyfin: Jellyfin,
|
||||||
|
private val sessionRepository: SessionRepository,
|
||||||
|
private val authenticationStore: AuthenticationStore,
|
||||||
|
private val userApiClient: ApiClient,
|
||||||
|
private val authenticationPreferences: AuthenticationPreferences,
|
||||||
|
private val defaultDeviceInfo: DeviceInfo,
|
||||||
|
) : AuthenticationRepository {
|
||||||
|
override fun authenticate(server: Server, method: AuthenticateMethod): Flow<LoginState> {
|
||||||
|
// Check server version first
|
||||||
|
if (!server.versionSupported) return flowOf(ServerVersionNotSupported(server))
|
||||||
|
|
||||||
|
return when (method) {
|
||||||
|
is AutomaticAuthenticateMethod -> authenticateAutomatic(server, method.user)
|
||||||
|
is CredentialAuthenticateMethod -> authenticateCredential(server, method.username, method.password)
|
||||||
|
is QuickConnectAuthenticateMethod -> authenticateQuickConnect(server, method.secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateAutomatic(server: Server, user: User): Flow<LoginState> {
|
||||||
|
Timber.i("Authenticating user ${user.id}")
|
||||||
|
|
||||||
|
// Automatic logic is disabled when the always authenticate preference is enabled
|
||||||
|
if (authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]) return flowOf(RequireSignInState)
|
||||||
|
|
||||||
|
val authStoreUser = authenticationStore.getUser(server.id, user.id)
|
||||||
|
// Try login with access token
|
||||||
|
return if (authStoreUser?.accessToken != null) authenticateToken(server, user.withToken(authStoreUser.accessToken))
|
||||||
|
// Require login
|
||||||
|
else flowOf(RequireSignInState)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateCredential(server: Server, username: String, password: String) = flow {
|
||||||
|
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo.forUser(username))
|
||||||
|
val result = try {
|
||||||
|
val response = api.userApi.authenticateUserByName(username, password)
|
||||||
|
response.content
|
||||||
|
} catch (err: TimeoutException) {
|
||||||
|
Timber.e(err, "Failed to connect to server trying to sign in $username")
|
||||||
|
emit(ServerUnavailableState)
|
||||||
|
return@flow
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to sign in as $username")
|
||||||
|
emit(ApiClientErrorLoginState(err))
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAll(authenticateAuthenticationResult(server, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateQuickConnect(server: Server, secret: String) = flow {
|
||||||
|
val api = jellyfin.createApi(server.address, deviceInfo = defaultDeviceInfo)
|
||||||
|
val result = try {
|
||||||
|
val response = api.userApi.authenticateWithQuickConnect(secret)
|
||||||
|
response.content
|
||||||
|
} catch (err: TimeoutException) {
|
||||||
|
Timber.e(err, "Failed to connect to server")
|
||||||
|
emit(ServerUnavailableState)
|
||||||
|
return@flow
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to sign in with Quick Connect secret")
|
||||||
|
emit(ApiClientErrorLoginState(err))
|
||||||
|
return@flow
|
||||||
|
}
|
||||||
|
|
||||||
|
emitAll(authenticateAuthenticationResult(server, result))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateAuthenticationResult(server: Server, result: AuthenticationResult) = flow {
|
||||||
|
val accessToken = result.accessToken ?: return@flow emit(RequireSignInState)
|
||||||
|
val userInfo = result.user ?: return@flow emit(RequireSignInState)
|
||||||
|
val user = PrivateUser(
|
||||||
|
id = userInfo.id,
|
||||||
|
serverId = server.id,
|
||||||
|
name = userInfo.name!!,
|
||||||
|
accessToken = result.accessToken,
|
||||||
|
imageTag = userInfo.primaryImageTag,
|
||||||
|
lastUsed = Instant.now().toEpochMilli(),
|
||||||
|
)
|
||||||
|
|
||||||
|
authenticateFinish(server, userInfo, accessToken)
|
||||||
|
val success = setActiveSession(user, server)
|
||||||
|
if (success) {
|
||||||
|
emit(AuthenticatedState)
|
||||||
|
} else {
|
||||||
|
Timber.w("Failed to set active session after authenticating")
|
||||||
|
emit(RequireSignInState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authenticateToken(server: Server, user: User) = flow {
|
||||||
|
emit(AuthenticatingState)
|
||||||
|
|
||||||
|
val success = setActiveSession(user, server)
|
||||||
|
if (!success) {
|
||||||
|
emit(RequireSignInState)
|
||||||
|
} else try {
|
||||||
|
// Update user info
|
||||||
|
val userInfo by userApiClient.userApi.getCurrentUser()
|
||||||
|
authenticateFinish(server, userInfo, user.accessToken.orEmpty())
|
||||||
|
emit(AuthenticatedState)
|
||||||
|
} catch (err: TimeoutException) {
|
||||||
|
Timber.e(err, "Failed to connect to server")
|
||||||
|
emit(ServerUnavailableState)
|
||||||
|
return@flow
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to get current user data")
|
||||||
|
emit(ApiClientErrorLoginState(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun authenticateFinish(server: Server, userInfo: UserDto, accessToken: String) {
|
||||||
|
val currentUser = authenticationStore.getUser(server.id, userInfo.id)
|
||||||
|
|
||||||
|
val updatedUser = currentUser?.copy(
|
||||||
|
name = userInfo.name!!,
|
||||||
|
lastUsed = Instant.now().toEpochMilli(),
|
||||||
|
imageTag = userInfo.primaryImageTag,
|
||||||
|
accessToken = accessToken,
|
||||||
|
) ?: AuthenticationStoreUser(
|
||||||
|
name = userInfo.name!!,
|
||||||
|
imageTag = userInfo.primaryImageTag,
|
||||||
|
accessToken = accessToken,
|
||||||
|
)
|
||||||
|
authenticationStore.putUser(server.id, userInfo.id, updatedUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setActiveSession(user: User, server: Server): Boolean {
|
||||||
|
val authenticated = sessionRepository.switchCurrentSession(server.id, user.id)
|
||||||
|
|
||||||
|
if (authenticated) {
|
||||||
|
// Update last use in store
|
||||||
|
authenticationStore.getServer(server.id)?.let { storedServer ->
|
||||||
|
authenticationStore.putServer(server.id, storedServer.copy(lastUsed = Instant.now().toEpochMilli()))
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticationStore.getUser(server.id, user.id)?.let { storedUser ->
|
||||||
|
authenticationStore.putUser(server.id, user.id, storedUser.copy(lastUsed = Instant.now().toEpochMilli()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return authenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun logout(user: User): Boolean {
|
||||||
|
val authStoreUser = authenticationStore
|
||||||
|
.getUser(user.serverId, user.id)
|
||||||
|
?.copy(accessToken = null)
|
||||||
|
|
||||||
|
return if (authStoreUser != null) authenticationStore.putUser(user.serverId, user.id, authStoreUser)
|
||||||
|
else false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUserImageUrl(server: Server, user: User): String? = user.imageTag?.let { tag ->
|
||||||
|
jellyfin.createApi(server.address).imageApi.getUserImageUrl(
|
||||||
|
userId = user.id,
|
||||||
|
tag = tag,
|
||||||
|
maxHeight = ImageHelper.MAX_PRIMARY_IMAGE_HEIGHT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,246 @@
|
||||||
|
package org.jellyfin.androidtv.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||||
|
import org.jellyfin.androidtv.auth.model.ConnectedState
|
||||||
|
import org.jellyfin.androidtv.auth.model.ConnectingState
|
||||||
|
import org.jellyfin.androidtv.auth.model.Server
|
||||||
|
import org.jellyfin.androidtv.auth.model.ServerAdditionState
|
||||||
|
import org.jellyfin.androidtv.auth.model.UnableToConnectState
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.jellyfin.androidtv.util.sdk.toServer
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.exception.InvalidContentException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.brandingApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
|
import org.jellyfin.sdk.model.api.BrandingOptions
|
||||||
|
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUID
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to maintain servers.
|
||||||
|
*/
|
||||||
|
interface ServerRepository {
|
||||||
|
val storedServers: StateFlow<List<Server>>
|
||||||
|
val discoveredServers: StateFlow<List<Server>>
|
||||||
|
|
||||||
|
suspend fun loadStoredServers()
|
||||||
|
suspend fun loadDiscoveryServers()
|
||||||
|
|
||||||
|
fun addServer(address: String): Flow<ServerAdditionState>
|
||||||
|
suspend fun getServer(id: UUID): Server?
|
||||||
|
suspend fun updateServer(server: Server): Boolean
|
||||||
|
suspend fun deleteServer(server: UUID): Boolean
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val minimumServerVersion = Jellyfin.minimumVersion.copy(build = null)
|
||||||
|
val recommendedServerVersion = Jellyfin.apiVersion.copy(build = null)
|
||||||
|
|
||||||
|
val upcomingMinimumServerVersion = ServerVersion(10, 10, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerRepositoryImpl(
|
||||||
|
private val jellyfin: Jellyfin,
|
||||||
|
private val authenticationStore: AuthenticationStore,
|
||||||
|
) : ServerRepository {
|
||||||
|
// State
|
||||||
|
private val _storedServers = MutableStateFlow(emptyList<Server>())
|
||||||
|
override val storedServers = _storedServers.asStateFlow()
|
||||||
|
|
||||||
|
private val _discoveredServers = MutableStateFlow(emptyList<Server>())
|
||||||
|
override val discoveredServers = _discoveredServers.asStateFlow()
|
||||||
|
|
||||||
|
// Loading data
|
||||||
|
override suspend fun loadStoredServers() {
|
||||||
|
authenticationStore.getServers()
|
||||||
|
.map { (id, entry) -> entry.asServer(id) }
|
||||||
|
.sortedWith(compareByDescending<Server> { it.dateLastAccessed }.thenBy { it.name })
|
||||||
|
.let { _storedServers.emit(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun loadDiscoveryServers() {
|
||||||
|
val servers = mutableListOf<Server>()
|
||||||
|
|
||||||
|
jellyfin.discovery
|
||||||
|
.discoverLocalServers()
|
||||||
|
.map(ServerDiscoveryInfo::toServer)
|
||||||
|
.collect { server ->
|
||||||
|
servers += server
|
||||||
|
_discoveredServers.emit(servers.toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutating data
|
||||||
|
override fun addServer(address: String): Flow<ServerAdditionState> = flow {
|
||||||
|
Timber.i("Adding server %s", address)
|
||||||
|
|
||||||
|
emit(ConnectingState(address))
|
||||||
|
|
||||||
|
val addressCandidates = jellyfin.discovery.getAddressCandidates(address)
|
||||||
|
Timber.i("Found ${addressCandidates.size} candidates")
|
||||||
|
|
||||||
|
val goodRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||||
|
val badRecommendations = mutableListOf<RecommendedServerInfo>()
|
||||||
|
val greatRecommendation = jellyfin.discovery.getRecommendedServers(addressCandidates).firstOrNull { recommendedServer ->
|
||||||
|
when (recommendedServer.score) {
|
||||||
|
RecommendedServerInfoScore.GREAT -> true
|
||||||
|
RecommendedServerInfoScore.GOOD -> {
|
||||||
|
goodRecommendations += recommendedServer
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
badRecommendations += recommendedServer
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.d(buildString {
|
||||||
|
append("Recommendations: ")
|
||||||
|
if (greatRecommendation == null) append(0)
|
||||||
|
else append(1)
|
||||||
|
append(" great, ")
|
||||||
|
append(goodRecommendations.size)
|
||||||
|
append(" good, ")
|
||||||
|
append(badRecommendations.size)
|
||||||
|
append(" bad")
|
||||||
|
})
|
||||||
|
|
||||||
|
val chosenRecommendation = greatRecommendation ?: goodRecommendations.firstOrNull()
|
||||||
|
if (chosenRecommendation != null && chosenRecommendation.systemInfo.isSuccess) {
|
||||||
|
// Get system info
|
||||||
|
val systemInfo = chosenRecommendation.systemInfo.getOrThrow()
|
||||||
|
|
||||||
|
// Get branding info
|
||||||
|
val api = jellyfin.createApi(chosenRecommendation.address)
|
||||||
|
val branding = api.getBrandingOptionsOrDefault()
|
||||||
|
|
||||||
|
val id = systemInfo.id!!.toUUID()
|
||||||
|
|
||||||
|
val server = authenticationStore.getServer(id)?.copy(
|
||||||
|
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||||
|
address = chosenRecommendation.address,
|
||||||
|
version = systemInfo.version,
|
||||||
|
loginDisclaimer = branding.loginDisclaimer,
|
||||||
|
splashscreenEnabled = branding.splashscreenEnabled,
|
||||||
|
setupCompleted = systemInfo.startupWizardCompleted ?: true,
|
||||||
|
lastUsed = Instant.now().toEpochMilli()
|
||||||
|
) ?: AuthenticationStoreServer(
|
||||||
|
name = systemInfo.serverName ?: "Jellyfin Server",
|
||||||
|
address = chosenRecommendation.address,
|
||||||
|
version = systemInfo.version,
|
||||||
|
loginDisclaimer = branding.loginDisclaimer,
|
||||||
|
splashscreenEnabled = branding.splashscreenEnabled,
|
||||||
|
setupCompleted = systemInfo.startupWizardCompleted ?: true,
|
||||||
|
)
|
||||||
|
|
||||||
|
authenticationStore.putServer(id, server)
|
||||||
|
loadStoredServers()
|
||||||
|
|
||||||
|
emit(ConnectedState(id, systemInfo))
|
||||||
|
} else {
|
||||||
|
// No great or good recommendations, only add bad recommendations
|
||||||
|
val addressCandidatesWithIssues = (badRecommendations + goodRecommendations)
|
||||||
|
.groupBy { it.address }
|
||||||
|
.mapValues { (_, entry) -> entry.flatMap { server -> server.issues } }
|
||||||
|
emit(UnableToConnectState(addressCandidatesWithIssues))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getServer(id: UUID): Server? {
|
||||||
|
val server = authenticationStore.getServer(id) ?: return null
|
||||||
|
|
||||||
|
val updatedServer = try {
|
||||||
|
updateServerInternal(id, server)
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to update server")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (updatedServer ?: server).asServer(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateServer(server: Server): Boolean {
|
||||||
|
// Only update existing servers
|
||||||
|
val serverInfo = authenticationStore.getServer(server.id) ?: return false
|
||||||
|
|
||||||
|
return try {
|
||||||
|
updateServerInternal(server.id, serverInfo) != null
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to update server")
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateServerInternal(id: UUID, server: AuthenticationStoreServer): AuthenticationStoreServer? {
|
||||||
|
val now = Instant.now().toEpochMilli()
|
||||||
|
|
||||||
|
// Only update every 10 minutes
|
||||||
|
if (now - server.lastRefreshed < 600000 && server.version != null) return null
|
||||||
|
|
||||||
|
val api = jellyfin.createApi(server.address)
|
||||||
|
// Get login disclaimer
|
||||||
|
val branding = api.getBrandingOptionsOrDefault()
|
||||||
|
val systemInfo by api.systemApi.getPublicSystemInfo()
|
||||||
|
|
||||||
|
val newServer = server.copy(
|
||||||
|
name = systemInfo.serverName ?: server.name,
|
||||||
|
version = systemInfo.version ?: server.version,
|
||||||
|
loginDisclaimer = branding.loginDisclaimer ?: server.loginDisclaimer,
|
||||||
|
splashscreenEnabled = branding.splashscreenEnabled,
|
||||||
|
setupCompleted = systemInfo.startupWizardCompleted ?: server.setupCompleted,
|
||||||
|
lastRefreshed = now
|
||||||
|
)
|
||||||
|
authenticationStore.putServer(id, newServer)
|
||||||
|
|
||||||
|
return newServer
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteServer(server: UUID): Boolean {
|
||||||
|
val success = authenticationStore.removeServer(server)
|
||||||
|
if (success) loadStoredServers()
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
private fun AuthenticationStoreServer.asServer(id: UUID) = Server(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
address = address,
|
||||||
|
version = version,
|
||||||
|
loginDisclaimer = loginDisclaimer,
|
||||||
|
splashscreenEnabled = splashscreenEnabled,
|
||||||
|
setupCompleted = setupCompleted,
|
||||||
|
dateLastAccessed = Instant.ofEpochMilli(lastUsed),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to retrieve the branding options. If the response JSON is invalid it will return a default value.
|
||||||
|
* This makes sure we can still work with older Jellyfin versions.
|
||||||
|
*/
|
||||||
|
private suspend fun ApiClient.getBrandingOptionsOrDefault() = try {
|
||||||
|
brandingApi.getBrandingOptions().content
|
||||||
|
} catch (exception: InvalidContentException) {
|
||||||
|
Timber.w(exception, "Invalid branding options response, using default value")
|
||||||
|
BrandingOptions(
|
||||||
|
loginDisclaimer = null,
|
||||||
|
customCss = null,
|
||||||
|
splashscreenEnabled = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
package org.jellyfin.androidtv.auth.repository
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.auth.model.PrivateUser
|
||||||
|
import org.jellyfin.androidtv.auth.model.PublicUser
|
||||||
|
import org.jellyfin.androidtv.auth.model.Server
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.jellyfin.androidtv.util.sdk.toPublicUser
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to maintain users for servers.
|
||||||
|
* Authentication is done using the [AuthenticationRepository].
|
||||||
|
*/
|
||||||
|
interface ServerUserRepository {
|
||||||
|
//server
|
||||||
|
fun getStoredServerUsers(server: Server): List<PrivateUser>
|
||||||
|
suspend fun getPublicServerUsers(server: Server): List<PublicUser>
|
||||||
|
|
||||||
|
fun deleteStoredUser(user: PrivateUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServerUserRepositoryImpl(
|
||||||
|
private val jellyfin: Jellyfin,
|
||||||
|
private val authenticationStore: AuthenticationStore,
|
||||||
|
) : ServerUserRepository {
|
||||||
|
override fun getStoredServerUsers(server: Server) = authenticationStore.getUsers(server.id)
|
||||||
|
?.mapNotNull { (userId, userInfo) ->
|
||||||
|
val authInfo = authenticationStore.getUser(server.id, userId)
|
||||||
|
PrivateUser(
|
||||||
|
id = userId,
|
||||||
|
serverId = server.id,
|
||||||
|
name = userInfo.name,
|
||||||
|
accessToken = authInfo?.accessToken,
|
||||||
|
imageTag = userInfo.imageTag,
|
||||||
|
lastUsed = userInfo.lastUsed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
?.sortedWith(compareByDescending<PrivateUser> { it.lastUsed }.thenBy { it.name })
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
|
override suspend fun getPublicServerUsers(server: Server): List<PublicUser> {
|
||||||
|
// Create a fresh API because the shared one might be authenticated for a different server
|
||||||
|
val api = jellyfin.createApi(server.address)
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val users by api.userApi.getPublicUsers()
|
||||||
|
users.mapNotNull(UserDto::toPublicUser)
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to retrieve public users")
|
||||||
|
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deleteStoredUser(user: PrivateUser) {
|
||||||
|
// Remove user info from store
|
||||||
|
authenticationStore.removeUser(user.serverId, user.id)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,206 @@
|
||||||
|
package org.jellyfin.androidtv.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||||
|
import org.jellyfin.androidtv.preference.TelemetryPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.DISABLED
|
||||||
|
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.LAST_USER
|
||||||
|
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior.SPECIFIC_USER
|
||||||
|
import org.jellyfin.androidtv.util.sdk.forUser
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.clientLogApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val userId: UUID,
|
||||||
|
val serverId: UUID,
|
||||||
|
val accessToken: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class SessionRepositoryState {
|
||||||
|
READY,
|
||||||
|
RESTORING_SESSION,
|
||||||
|
SWITCHING_SESSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionRepository {
|
||||||
|
val currentSession: StateFlow<Session?>
|
||||||
|
val state: StateFlow<SessionRepositoryState>
|
||||||
|
|
||||||
|
suspend fun restoreSession(destroyOnly: Boolean)
|
||||||
|
suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean
|
||||||
|
fun destroyCurrentSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionRepositoryImpl(
|
||||||
|
private val authenticationPreferences: AuthenticationPreferences,
|
||||||
|
private val apiBinder: ApiBinder,
|
||||||
|
private val authenticationStore: AuthenticationStore,
|
||||||
|
private val userApiClient: ApiClient,
|
||||||
|
private val preferencesRepository: PreferencesRepository,
|
||||||
|
private val defaultDeviceInfo: DeviceInfo,
|
||||||
|
private val userRepository: UserRepository,
|
||||||
|
private val serverRepository: ServerRepository,
|
||||||
|
private val telemetryPreferences: TelemetryPreferences,
|
||||||
|
) : SessionRepository {
|
||||||
|
private val currentSessionMutex = Mutex()
|
||||||
|
private val _currentSession = MutableStateFlow<Session?>(null)
|
||||||
|
override val currentSession = _currentSession.asStateFlow()
|
||||||
|
private val _state = MutableStateFlow(SessionRepositoryState.READY)
|
||||||
|
override val state = _state.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun restoreSession(destroyOnly: Boolean): Unit = withContext(NonCancellable) {
|
||||||
|
currentSessionMutex.withLock {
|
||||||
|
Timber.i("Restoring session")
|
||||||
|
|
||||||
|
_state.value = SessionRepositoryState.RESTORING_SESSION
|
||||||
|
|
||||||
|
val alwaysAuthenticate = authenticationPreferences[AuthenticationPreferences.alwaysAuthenticate]
|
||||||
|
val autoLoginBehavior = authenticationPreferences[AuthenticationPreferences.autoLoginUserBehavior]
|
||||||
|
|
||||||
|
when {
|
||||||
|
alwaysAuthenticate -> destroyCurrentSession()
|
||||||
|
autoLoginBehavior == DISABLED -> destroyCurrentSession()
|
||||||
|
autoLoginBehavior == LAST_USER && !destroyOnly -> setCurrentSession(createLastUserSession())
|
||||||
|
autoLoginBehavior == SPECIFIC_USER && !destroyOnly -> {
|
||||||
|
val serverId = authenticationPreferences[AuthenticationPreferences.autoLoginServerId].toUUIDOrNull()
|
||||||
|
val userId = authenticationPreferences[AuthenticationPreferences.autoLoginUserId].toUUIDOrNull()
|
||||||
|
if (serverId != null && userId != null) setCurrentSession(createUserSession(serverId, userId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = SessionRepositoryState.READY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun switchCurrentSession(serverId: UUID, userId: UUID): Boolean {
|
||||||
|
// No change in user - don't switch
|
||||||
|
if (currentSession.value?.userId == userId) {
|
||||||
|
Timber.d("Current session user is the same as the requested user")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.value = SessionRepositoryState.SWITCHING_SESSION
|
||||||
|
Timber.i("Switching current session to user $userId")
|
||||||
|
|
||||||
|
val session = createUserSession(serverId, userId)
|
||||||
|
if (session == null) {
|
||||||
|
Timber.w("Could not switch to non-existing session for user $userId")
|
||||||
|
_state.value = SessionRepositoryState.READY
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val switched = setCurrentSession(session)
|
||||||
|
_state.value = SessionRepositoryState.READY
|
||||||
|
return switched
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun destroyCurrentSession() {
|
||||||
|
Timber.i("Destroying current session")
|
||||||
|
|
||||||
|
userRepository.updateCurrentUser(null)
|
||||||
|
_currentSession.value = null
|
||||||
|
apiBinder.updateSession(null, userApiClient.deviceInfo)
|
||||||
|
_state.value = SessionRepositoryState.READY
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setCurrentSession(session: Session?): Boolean {
|
||||||
|
if (session != null) {
|
||||||
|
// No change in session - don't switch
|
||||||
|
if (currentSession.value?.userId == session.userId) return true
|
||||||
|
|
||||||
|
// Update last active user
|
||||||
|
authenticationPreferences[AuthenticationPreferences.lastServerId] = session.serverId.toString()
|
||||||
|
authenticationPreferences[AuthenticationPreferences.lastUserId] = session.userId.toString()
|
||||||
|
|
||||||
|
// Check if server version is supported
|
||||||
|
val server = serverRepository.getServer(session.serverId)
|
||||||
|
if (server == null || !server.versionSupported) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session after binding the apiclient settings
|
||||||
|
val deviceInfo = session?.let { defaultDeviceInfo.forUser(it.userId) } ?: defaultDeviceInfo
|
||||||
|
val success = apiBinder.updateSession(session, deviceInfo)
|
||||||
|
Timber.i("Updating current session. userId=${session?.userId} apiBindingSuccess=${success}")
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
val applied = userApiClient.applySession(session, deviceInfo)
|
||||||
|
|
||||||
|
if (applied && session != null) {
|
||||||
|
try {
|
||||||
|
val user by userApiClient.userApi.getCurrentUser()
|
||||||
|
userRepository.updateCurrentUser(user)
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to authenticate: bad response when getting user info")
|
||||||
|
destroyCurrentSession()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update crash reporting URL
|
||||||
|
val crashReportUrl = userApiClient.clientLogApi.logFileUrl()
|
||||||
|
telemetryPreferences[TelemetryPreferences.crashReportUrl] = crashReportUrl
|
||||||
|
telemetryPreferences[TelemetryPreferences.crashReportToken] = session.accessToken
|
||||||
|
} else {
|
||||||
|
userRepository.updateCurrentUser(null)
|
||||||
|
}
|
||||||
|
preferencesRepository.onSessionChanged()
|
||||||
|
_currentSession.value = session
|
||||||
|
} else destroyCurrentSession()
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createLastUserSession(): Session? {
|
||||||
|
val lastUserId = authenticationPreferences[AuthenticationPreferences.lastUserId].toUUIDOrNull()
|
||||||
|
val lastServerId = authenticationPreferences[AuthenticationPreferences.lastServerId].toUUIDOrNull()
|
||||||
|
|
||||||
|
return if (lastUserId != null && lastServerId != null) createUserSession(lastServerId, lastUserId)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createUserSession(serverId: UUID, userId: UUID): Session? {
|
||||||
|
val account = authenticationStore.getUser(serverId, userId)
|
||||||
|
if (account?.accessToken == null) return null
|
||||||
|
|
||||||
|
return Session(
|
||||||
|
userId = userId,
|
||||||
|
serverId = serverId,
|
||||||
|
accessToken = account.accessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ApiClient.applySession(session: Session?, newDeviceInfo: DeviceInfo = defaultDeviceInfo): Boolean {
|
||||||
|
if (session == null) {
|
||||||
|
update(
|
||||||
|
baseUrl = null,
|
||||||
|
accessToken = null,
|
||||||
|
deviceInfo = newDeviceInfo,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val server = authenticationStore.getServer(session.serverId)
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
update(
|
||||||
|
baseUrl = server.address,
|
||||||
|
accessToken = session.accessToken,
|
||||||
|
deviceInfo = newDeviceInfo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.jellyfin.androidtv.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.jellyfin.sdk.model.api.UserDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to get the current authenticated user.
|
||||||
|
*/
|
||||||
|
interface UserRepository {
|
||||||
|
val currentUser: StateFlow<UserDto?>
|
||||||
|
|
||||||
|
fun updateCurrentUser(user: UserDto?)
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRepositoryImpl : UserRepository {
|
||||||
|
override val currentUser = MutableStateFlow<UserDto?>(null)
|
||||||
|
|
||||||
|
override fun updateCurrentUser(user: UserDto?) {
|
||||||
|
currentUser.value = user
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
package org.jellyfin.androidtv.auth.service
|
||||||
|
|
||||||
|
import android.accounts.AbstractAccountAuthenticator
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.accounts.AccountAuthenticatorResponse
|
||||||
|
import android.accounts.AccountManager
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import org.jellyfin.androidtv.ui.preference.PreferencesActivity
|
||||||
|
|
||||||
|
class AuthenticatorService : Service() {
|
||||||
|
private val authenticator by lazy {
|
||||||
|
Authenticator(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent): IBinder? = authenticator.iBinder
|
||||||
|
|
||||||
|
private inner class Authenticator(
|
||||||
|
private val service: AuthenticatorService
|
||||||
|
) : AbstractAccountAuthenticator(service) {
|
||||||
|
private val unsupportedOperationBundle = bundleOf(
|
||||||
|
AccountManager.KEY_ERROR_CODE to AccountManager.ERROR_CODE_REMOTE_EXCEPTION,
|
||||||
|
AccountManager.KEY_ERROR_MESSAGE to "Unsupported operation"
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun addAccount(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
accountType: String,
|
||||||
|
authTokenType: String?,
|
||||||
|
requiredFeatures: Array<String>?,
|
||||||
|
options: Bundle,
|
||||||
|
): Bundle = unsupportedOperationBundle
|
||||||
|
|
||||||
|
override fun confirmCredentials(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
account: Account,
|
||||||
|
options: Bundle?,
|
||||||
|
): Bundle = unsupportedOperationBundle
|
||||||
|
|
||||||
|
override fun editProperties(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
accountType: String,
|
||||||
|
): Bundle = bundleOf(
|
||||||
|
AccountManager.KEY_INTENT to Intent(service, PreferencesActivity::class.java)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getAccountRemovalAllowed(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
account: Account,
|
||||||
|
): Bundle = bundleOf(
|
||||||
|
AccountManager.KEY_BOOLEAN_RESULT to true
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getAuthToken(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
account: Account,
|
||||||
|
authTokenType: String,
|
||||||
|
options: Bundle,
|
||||||
|
): Bundle = unsupportedOperationBundle
|
||||||
|
|
||||||
|
override fun getAuthTokenLabel(authTokenType: String): String? = null
|
||||||
|
|
||||||
|
override fun hasFeatures(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
account: Account,
|
||||||
|
features: Array<String>,
|
||||||
|
): Bundle = unsupportedOperationBundle
|
||||||
|
|
||||||
|
override fun updateCredentials(
|
||||||
|
response: AccountAuthenticatorResponse,
|
||||||
|
account: Account,
|
||||||
|
authTokenType: String?,
|
||||||
|
options: Bundle?,
|
||||||
|
): Bundle = unsupportedOperationBundle
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package org.jellyfin.androidtv.auth.store
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationSortBy
|
||||||
|
import org.jellyfin.androidtv.preference.constant.UserSelectBehavior
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.enumPreference
|
||||||
|
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||||
|
import org.jellyfin.preference.stringPreference
|
||||||
|
|
||||||
|
class AuthenticationPreferences(context: Context) : SharedPreferenceStore(
|
||||||
|
sharedPreferences = context.getSharedPreferences("authentication", Context.MODE_PRIVATE)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Preferences
|
||||||
|
val autoLoginUserBehavior = enumPreference("auto_login_user_behavior", UserSelectBehavior.LAST_USER)
|
||||||
|
val autoLoginServerId = stringPreference("auto_login_server_id", "")
|
||||||
|
val autoLoginUserId = stringPreference("auto_login_user_id", "")
|
||||||
|
|
||||||
|
val sortBy = enumPreference("sort_by", AuthenticationSortBy.LAST_USE)
|
||||||
|
val alwaysAuthenticate = booleanPreference("always_authenticate", false)
|
||||||
|
|
||||||
|
// Persistent state
|
||||||
|
val lastServerId = stringPreference("last_server_id", "")
|
||||||
|
val lastUserId = stringPreference("last_user_id", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
runMigrations {
|
||||||
|
// v0.15.4 to v0.15.5
|
||||||
|
migration(toVersion = 2) {
|
||||||
|
// Unfortunately we cannot migrate the "specific user" login option
|
||||||
|
// so we'll reset the preference to disabled if it was used
|
||||||
|
if (it.getString("auto_login_user_behavior", null) === UserSelectBehavior.SPECIFIC_USER.name) {
|
||||||
|
putString("auto_login_user_id", "")
|
||||||
|
putString("auto_login_user_behavior", UserSelectBehavior.DISABLED.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
putString("last_user_id", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
package org.jellyfin.androidtv.auth.store
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.encodeToJsonElement
|
||||||
|
import kotlinx.serialization.json.intOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.modules.contextual
|
||||||
|
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationStoreServer
|
||||||
|
import org.jellyfin.androidtv.auth.model.AuthenticationStoreUser
|
||||||
|
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Storage for authentication related entities. Stores servers with users inside, including
|
||||||
|
* access tokens.
|
||||||
|
*
|
||||||
|
* The data is stored in a JSON file located in the applications data directory.
|
||||||
|
*/
|
||||||
|
class AuthenticationStore(
|
||||||
|
private val context: Context,
|
||||||
|
private val accountManagerMigration: AccountManagerMigration,
|
||||||
|
) {
|
||||||
|
private val storePath
|
||||||
|
get() = context.filesDir.resolve("authentication_store.json")
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
encodeDefaults = true
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(UUIDSerializer())
|
||||||
|
}
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private val store by lazy {
|
||||||
|
load().toMutableMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(): Map<UUID, AuthenticationStoreServer> {
|
||||||
|
// No store found
|
||||||
|
if (!storePath.exists()) return emptyMap()
|
||||||
|
|
||||||
|
// Parse JSON document
|
||||||
|
val root = try {
|
||||||
|
json.parseToJsonElement(storePath.readText()).jsonObject
|
||||||
|
} catch (e: SerializationException) {
|
||||||
|
Timber.e(e, "Unable to read JSON")
|
||||||
|
JsonObject(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for version
|
||||||
|
return when (root["version"]?.jsonPrimitive?.intOrNull) {
|
||||||
|
1 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||||
|
// Add access tokens from account manager to stored users and save the migrated data
|
||||||
|
.let { servers -> accountManagerMigration.migrate(servers) }
|
||||||
|
.also { servers -> write(servers) }
|
||||||
|
|
||||||
|
2 -> json.decodeFromJsonElement<Map<UUID, AuthenticationStoreServer>>(root["servers"]!!)
|
||||||
|
|
||||||
|
null -> {
|
||||||
|
Timber.e("Authentication Store is corrupt!")
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Timber.e("Authentication Store is using an unknown version!")
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(servers: Map<UUID, AuthenticationStoreServer>): Boolean {
|
||||||
|
val root = JsonObject(mapOf(
|
||||||
|
"version" to JsonPrimitive(2),
|
||||||
|
"servers" to json.encodeToJsonElement(servers)
|
||||||
|
))
|
||||||
|
|
||||||
|
storePath.writeText(json.encodeToString(root))
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(): Boolean {
|
||||||
|
return write(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getServers(): Map<UUID, AuthenticationStoreServer> = store
|
||||||
|
|
||||||
|
fun getUsers(server: UUID): Map<UUID, AuthenticationStoreUser>? = getServer(server)?.users
|
||||||
|
|
||||||
|
fun getServer(serverId: UUID) = store[serverId]
|
||||||
|
|
||||||
|
fun getUser(serverId: UUID, userId: UUID) = getUsers(serverId)?.get(userId)
|
||||||
|
|
||||||
|
fun putServer(id: UUID, server: AuthenticationStoreServer): Boolean {
|
||||||
|
store[id] = server
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putUser(server: UUID, userId: UUID, userInfo: AuthenticationStoreUser): Boolean {
|
||||||
|
val serverInfo = store[server] ?: return false
|
||||||
|
|
||||||
|
store[server] = serverInfo.copy(users = serverInfo.users.toMutableMap().apply { put(userId, userInfo) })
|
||||||
|
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the server and stored users from the credential store.
|
||||||
|
*/
|
||||||
|
fun removeServer(server: UUID): Boolean {
|
||||||
|
store.remove(server)
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeUser(server: UUID, user: UUID): Boolean {
|
||||||
|
val serverInfo = store[server] ?: return false
|
||||||
|
|
||||||
|
store[server] = serverInfo.copy(users = serverInfo.users.toMutableMap().apply { remove(user) })
|
||||||
|
|
||||||
|
return save()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
enum class ChangeTriggerType {
|
||||||
|
LibraryUpdated,
|
||||||
|
MoviePlayback,
|
||||||
|
TvPlayback,
|
||||||
|
MusicPlayback,
|
||||||
|
FavoriteUpdate,
|
||||||
|
}
|
88
app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt
Normal file
88
app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
object Codec {
|
||||||
|
object Container {
|
||||||
|
@Suppress("ObjectPropertyName", "ObjectPropertyNaming")
|
||||||
|
const val `3GP` = "3gp"
|
||||||
|
const val ASF = "asf"
|
||||||
|
const val AVI = "avi"
|
||||||
|
const val DVR_MS = "dvr-ms"
|
||||||
|
const val HLS = "hls"
|
||||||
|
const val M2V = "m2v"
|
||||||
|
const val M4V = "m4v"
|
||||||
|
const val MKV = "mkv"
|
||||||
|
const val MOV = "mov"
|
||||||
|
const val MP3 = "mp3"
|
||||||
|
const val MP4 = "mp4"
|
||||||
|
const val MPEG = "mpeg"
|
||||||
|
const val MPEGTS = "mpegts"
|
||||||
|
const val MPG = "mpg"
|
||||||
|
const val OGM = "ogm"
|
||||||
|
const val OGV = "ogv"
|
||||||
|
const val TS = "ts"
|
||||||
|
const val VOB = "vob"
|
||||||
|
const val WEBM = "webm"
|
||||||
|
const val WMV = "wmv"
|
||||||
|
const val WTV = "wtv"
|
||||||
|
const val XVID = "xvid"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Audio {
|
||||||
|
const val AAC = "aac"
|
||||||
|
const val AAC_LATM = "aac_latm"
|
||||||
|
const val AC3 = "ac3"
|
||||||
|
const val ALAC = "alac"
|
||||||
|
const val APE = "ape"
|
||||||
|
const val DCA = "dca"
|
||||||
|
const val DTS = "dts"
|
||||||
|
const val EAC3 = "eac3"
|
||||||
|
const val FLAC = "flac"
|
||||||
|
const val MLP = "mlp"
|
||||||
|
const val MP2 = "mp2"
|
||||||
|
const val MP3 = "mp3"
|
||||||
|
const val MPA = "mpa"
|
||||||
|
const val OGA = "oga"
|
||||||
|
const val OGG = "ogg"
|
||||||
|
const val OPUS = "opus"
|
||||||
|
const val PCM = "pcm"
|
||||||
|
const val PCM_ALAW = "pcm_alaw"
|
||||||
|
const val PCM_MULAW = "pcm_mulaw"
|
||||||
|
const val PCM_S16LE = "pcm_s16le"
|
||||||
|
const val PCM_S20LE = "pcm_s20le"
|
||||||
|
const val PCM_S24LE = "pcm_s24le"
|
||||||
|
const val TRUEHD = "truehd"
|
||||||
|
const val VORBIS = "vorbis"
|
||||||
|
const val WAV = "wav"
|
||||||
|
const val WEBMA = "webma"
|
||||||
|
const val WMA = "wma"
|
||||||
|
const val WMAV2 = "wmav2"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Video {
|
||||||
|
const val H264 = "h264"
|
||||||
|
const val HEVC = "hevc"
|
||||||
|
const val MPEG = "mpeg"
|
||||||
|
const val MPEG2VIDEO = "mpeg2video"
|
||||||
|
const val VP8 = "vp8"
|
||||||
|
const val VP9 = "vp9"
|
||||||
|
const val AV1 = "av1"
|
||||||
|
}
|
||||||
|
|
||||||
|
object Subtitle {
|
||||||
|
const val ASS = "ass"
|
||||||
|
const val DVBSUB = "dvbsub"
|
||||||
|
const val DVDSUB = "dvdsub"
|
||||||
|
const val IDX = "idx"
|
||||||
|
const val PGS = "pgs"
|
||||||
|
const val PGSSUB = "pgssub"
|
||||||
|
const val SMI = "smi"
|
||||||
|
const val SRT = "srt"
|
||||||
|
const val SSA = "ssa"
|
||||||
|
const val SUB = "sub"
|
||||||
|
const val SUBRIP = "subrip"
|
||||||
|
const val VTT = "vtt"
|
||||||
|
const val SMIL = "smil"
|
||||||
|
const val TTML = "ttml"
|
||||||
|
const val WEBVTT = "webvtt"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
sealed interface CustomMessage {
|
||||||
|
data object RefreshCurrentItem : CustomMessage
|
||||||
|
data object ActionComplete : CustomMessage
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
object Extras {
|
||||||
|
const val Folder = "folder"
|
||||||
|
const val IsLiveTvSeriesRecordings = "is_livetv_series_recordings"
|
||||||
|
const val IncludeType = "type_include"
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class GridDirection(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Horizontal.
|
||||||
|
*/
|
||||||
|
HORIZONTAL(R.string.grid_direction_horizontal),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertical.
|
||||||
|
*/
|
||||||
|
VERTICAL(R.string.grid_direction_vertical),
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All possible homesections, "synced" with jellyfin-web.
|
||||||
|
*
|
||||||
|
* https://github.com/jellyfin/jellyfin-web/blob/master/src/components/homesections/homesections.js
|
||||||
|
*/
|
||||||
|
enum class HomeSectionType(
|
||||||
|
override val serializedName: String,
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
LATEST_MEDIA("latestmedia", R.string.home_section_latest_media),
|
||||||
|
LIBRARY_TILES_SMALL("smalllibrarytiles", R.string.home_section_library),
|
||||||
|
LIBRARY_BUTTONS("librarybuttons", R.string.home_section_library_small),
|
||||||
|
RESUME("resume", R.string.home_section_resume),
|
||||||
|
RESUME_AUDIO("resumeaudio", R.string.home_section_resume_audio),
|
||||||
|
RESUME_BOOK("resumebook", R.string.home_section_resume_book),
|
||||||
|
ACTIVE_RECORDINGS("activerecordings", R.string.home_section_active_recordings),
|
||||||
|
NEXT_UP("nextup", R.string.home_section_next_up),
|
||||||
|
LIVE_TV("livetv", R.string.home_section_livetv),
|
||||||
|
NONE("none", R.string.home_section_none),
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class ImageType(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Poster.
|
||||||
|
*/
|
||||||
|
POSTER(R.string.image_type_poster),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail.
|
||||||
|
*/
|
||||||
|
THUMB(R.string.image_type_thumbnail),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner.
|
||||||
|
*/
|
||||||
|
BANNER(R.string.image_type_banner),
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
object LiveTvOption {
|
||||||
|
const val LIVE_TV_GUIDE_OPTION_ID = 1000
|
||||||
|
const val LIVE_TV_RECORDINGS_OPTION_ID = 2000
|
||||||
|
const val LIVE_TV_SCHEDULE_OPTION_ID = 4000
|
||||||
|
const val LIVE_TV_SERIES_OPTION_ID = 5000
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class PosterSize(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Smallest.
|
||||||
|
*/
|
||||||
|
SMALLEST(R.string.image_size_smallest),
|
||||||
|
/**
|
||||||
|
* Small.
|
||||||
|
*/
|
||||||
|
SMALL(R.string.image_size_small),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Medium.
|
||||||
|
*/
|
||||||
|
MED(R.string.image_size_medium),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Large.
|
||||||
|
*/
|
||||||
|
LARGE(R.string.image_size_large),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra Large.
|
||||||
|
*/
|
||||||
|
X_LARGE(R.string.image_size_xlarge),
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
private val qualityOptions = setOf(
|
||||||
|
200.0, 180.0, 140.0, 120.0, 110.0, 100.0, // 100 >=
|
||||||
|
90.0, 80.0, 70.0, 60.0, 50.0, 40.0, 30.0, 20.0, 15.0, 10.0, // 10 >=
|
||||||
|
5.0, 3.0, 2.0, 1.0, // 1 >=
|
||||||
|
0.72, 0.42 // 0 >=
|
||||||
|
)
|
||||||
|
|
||||||
|
@Suppress("MagicNumber")
|
||||||
|
fun getQualityProfiles(
|
||||||
|
context: Context
|
||||||
|
): Map<String, String> = qualityOptions.associate {
|
||||||
|
val value = when {
|
||||||
|
it >= 1.0 -> context.getString(R.string.bitrate_mbit, it)
|
||||||
|
else -> context.getString(R.string.bitrate_kbit, it * 1000.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
it.toString().removeSuffix(".0") to value
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.jellyfin.androidtv.constant
|
||||||
|
|
||||||
|
enum class QueryType {
|
||||||
|
Items,
|
||||||
|
NextUp,
|
||||||
|
Views,
|
||||||
|
Season,
|
||||||
|
Upcoming,
|
||||||
|
SimilarSeries,
|
||||||
|
SimilarMovies,
|
||||||
|
StaticPeople,
|
||||||
|
StaticChapters,
|
||||||
|
Search,
|
||||||
|
Specials,
|
||||||
|
AdditionalParts,
|
||||||
|
Trailers,
|
||||||
|
LiveTvChannel,
|
||||||
|
LiveTvProgram,
|
||||||
|
LiveTvRecording,
|
||||||
|
StaticItems,
|
||||||
|
StaticAudioQueueItems,
|
||||||
|
Artists,
|
||||||
|
AlbumArtists,
|
||||||
|
AudioPlaylists,
|
||||||
|
LatestItems,
|
||||||
|
SeriesTimer,
|
||||||
|
Premieres,
|
||||||
|
Resume,
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat
|
||||||
|
|
||||||
|
import org.jellyfin.apiclient.model.dlna.DeviceProfile
|
||||||
|
import org.jellyfin.apiclient.model.dlna.EncodingContext
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
open class AudioOptions {
|
||||||
|
var enableDirectPlay = true
|
||||||
|
var enableDirectStream = true
|
||||||
|
var itemId: UUID? = null
|
||||||
|
var mediaSources: List<MediaSourceInfo>? = null
|
||||||
|
var profile: DeviceProfile? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. Only needed if a specific AudioStreamIndex or SubtitleStreamIndex are requested.
|
||||||
|
*/
|
||||||
|
var mediaSourceId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows an override of supported number of audio channels
|
||||||
|
* Example: DeviceProfile supports five channel, but user only has stereo speakers
|
||||||
|
*/
|
||||||
|
var maxAudioChannels: Int? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The application's configured quality setting
|
||||||
|
*/
|
||||||
|
var maxBitrate: Int? = null
|
||||||
|
|
||||||
|
var context = EncodingContext.Streaming
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat
|
||||||
|
|
||||||
|
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
|
||||||
|
|
||||||
|
class PlaybackException : RuntimeException() {
|
||||||
|
var errorCode = PlaybackErrorCode.NotAllowed
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat;
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.util.Utils;
|
||||||
|
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod;
|
||||||
|
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||||
|
import org.jellyfin.apiclient.model.entities.MediaStream;
|
||||||
|
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||||
|
|
||||||
|
public class StreamBuilder
|
||||||
|
{
|
||||||
|
public static SubtitleProfile getSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod)
|
||||||
|
{
|
||||||
|
if (playMethod != PlayMethod.Transcode && !subtitleStream.isExternal())
|
||||||
|
{
|
||||||
|
// Look for supported embedded subs
|
||||||
|
for (SubtitleProfile profile : subtitleProfiles)
|
||||||
|
{
|
||||||
|
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.getMethod() != SubtitleDeliveryMethod.Embed)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat()) && Utils.equalsIgnoreCase(profile.getFormat(), subtitleStream.getCodec()))
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for an external or hls profile that matches the stream type (text/graphical) and doesn't require conversion
|
||||||
|
SubtitleProfile tempVar = new SubtitleProfile();
|
||||||
|
tempVar.setMethod(SubtitleDeliveryMethod.Encode);
|
||||||
|
tempVar.setFormat(subtitleStream.getCodec());
|
||||||
|
SubtitleProfile tempVar2 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, false);
|
||||||
|
SubtitleProfile tempVar3 = getExternalSubtitleProfile(subtitleStream, subtitleProfiles, playMethod, true);
|
||||||
|
return (tempVar2 != null) ? tempVar2 : (tempVar3 != null) ? tempVar3 : tempVar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubtitleProfile getExternalSubtitleProfile(org.jellyfin.sdk.model.api.MediaStream subtitleStream, SubtitleProfile[] subtitleProfiles, PlayMethod playMethod, boolean allowConversion)
|
||||||
|
{
|
||||||
|
for (SubtitleProfile profile : subtitleProfiles)
|
||||||
|
{
|
||||||
|
if (profile.getMethod() != SubtitleDeliveryMethod.External && profile.getMethod() != SubtitleDeliveryMethod.Hls)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.getMethod() == SubtitleDeliveryMethod.Hls && playMethod != PlayMethod.Transcode)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile.SupportsLanguage(subtitleStream.getLanguage()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((profile.getMethod() == SubtitleDeliveryMethod.External && subtitleStream.isTextSubtitleStream() == MediaStream.IsTextFormat(profile.getFormat())) || (profile.getMethod() == SubtitleDeliveryMethod.Hls && subtitleStream.isTextSubtitleStream()))
|
||||||
|
{
|
||||||
|
boolean requiresConversion = !Utils.equalsIgnoreCase(subtitleStream.getCodec(), profile.getFormat());
|
||||||
|
|
||||||
|
if (!requiresConversion)
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowConversion)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subtitleStream.isTextSubtitleStream() && subtitleStream.getSupportsExternalStream() && supportsSubtitleConversionTo(subtitleStream, profile.getFormat()))
|
||||||
|
{
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean supportsSubtitleConversionTo(org.jellyfin.sdk.model.api.MediaStream mediaStream, String codec)
|
||||||
|
{
|
||||||
|
if (!mediaStream.isTextSubtitleStream())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can't convert from this
|
||||||
|
return !("ass".equalsIgnoreCase(mediaStream.getCodec()) || "ssa".equalsIgnoreCase(mediaStream.getCodec()) || "ass".equalsIgnoreCase(codec) || "ssa".equalsIgnoreCase(codec));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat;
|
||||||
|
|
||||||
|
import org.jellyfin.apiclient.model.dlna.DeviceProfile;
|
||||||
|
import org.jellyfin.apiclient.model.dlna.EncodingContext;
|
||||||
|
import org.jellyfin.apiclient.model.dlna.SubtitleProfile;
|
||||||
|
import org.jellyfin.apiclient.model.dlna.TranscodeSeekInfo;
|
||||||
|
import org.jellyfin.apiclient.model.session.PlayMethod;
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient;
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo;
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStream;
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType;
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class StreamInfo {
|
||||||
|
private UUID ItemId;
|
||||||
|
|
||||||
|
public final UUID getItemId() {
|
||||||
|
return ItemId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setItemId(UUID value) {
|
||||||
|
ItemId = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String MediaUrl;
|
||||||
|
|
||||||
|
public final String getMediaUrl() {
|
||||||
|
return MediaUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setMediaUrl(String value) {
|
||||||
|
MediaUrl = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayMethod playMethod = PlayMethod.DirectPlay;
|
||||||
|
|
||||||
|
public final PlayMethod getPlayMethod() {
|
||||||
|
return playMethod;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setPlayMethod(PlayMethod value) {
|
||||||
|
playMethod = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EncodingContext Context = EncodingContext.values()[0];
|
||||||
|
|
||||||
|
public final EncodingContext getContext() {
|
||||||
|
return Context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setContext(EncodingContext value) {
|
||||||
|
Context = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String Container;
|
||||||
|
|
||||||
|
public final String getContainer() {
|
||||||
|
return Container;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setContainer(String value) {
|
||||||
|
Container = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeviceProfile DeviceProfile;
|
||||||
|
|
||||||
|
public final DeviceProfile getDeviceProfile() {
|
||||||
|
return DeviceProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setDeviceProfile(DeviceProfile value) {
|
||||||
|
DeviceProfile = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long RunTimeTicks = null;
|
||||||
|
|
||||||
|
public final Long getRunTimeTicks() {
|
||||||
|
return RunTimeTicks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setRunTimeTicks(Long value) {
|
||||||
|
RunTimeTicks = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranscodeSeekInfo TranscodeSeekInfo = getTranscodeSeekInfo().values()[0];
|
||||||
|
|
||||||
|
public final TranscodeSeekInfo getTranscodeSeekInfo() {
|
||||||
|
return TranscodeSeekInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MediaSourceInfo MediaSource;
|
||||||
|
|
||||||
|
public final MediaSourceInfo getMediaSource() {
|
||||||
|
return MediaSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setMediaSource(MediaSourceInfo value) {
|
||||||
|
MediaSource = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final org.jellyfin.sdk.model.api.SubtitleDeliveryMethod getSubtitleDeliveryMethod() {
|
||||||
|
Integer subtitleStreamIndex = MediaSource.getDefaultSubtitleStreamIndex();
|
||||||
|
if (subtitleStreamIndex == null || subtitleStreamIndex == -1) return SubtitleDeliveryMethod.DROP;
|
||||||
|
return MediaSource.getMediaStreams().get(subtitleStreamIndex).getDeliveryMethod();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String PlaySessionId;
|
||||||
|
|
||||||
|
public final String getPlaySessionId() {
|
||||||
|
return PlaySessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final void setPlaySessionId(String value) {
|
||||||
|
PlaySessionId = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final String getMediaSourceId() {
|
||||||
|
return getMediaSource() == null ? null : getMediaSource().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public final ArrayList<SubtitleStreamInfo> getSubtitleProfiles(ApiClient api) {
|
||||||
|
ArrayList<SubtitleStreamInfo> list = new ArrayList<SubtitleStreamInfo>();
|
||||||
|
|
||||||
|
if (getMediaSource() == null) return list;
|
||||||
|
|
||||||
|
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||||
|
if (stream.getType() == org.jellyfin.sdk.model.api.MediaStreamType.SUBTITLE) {
|
||||||
|
SubtitleStreamInfo info = getSubtitleStreamInfo(api, stream, getDeviceProfile().getSubtitleProfiles());
|
||||||
|
list.add(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubtitleStreamInfo getSubtitleStreamInfo(ApiClient api, org.jellyfin.sdk.model.api.MediaStream stream, SubtitleProfile[] subtitleProfiles) {
|
||||||
|
SubtitleProfile subtitleProfile = StreamBuilder.getSubtitleProfile(stream, subtitleProfiles, getPlayMethod());
|
||||||
|
SubtitleStreamInfo info = new SubtitleStreamInfo();
|
||||||
|
String tempVar2 = stream.getLanguage();
|
||||||
|
info.setName((tempVar2 != null) ? tempVar2 : "Unknown");
|
||||||
|
info.setFormat(subtitleProfile.getFormat());
|
||||||
|
info.setIndex(stream.getIndex());
|
||||||
|
info.setDeliveryMethod(subtitleProfile.getMethod());
|
||||||
|
info.setDisplayTitle(stream.getDisplayTitle());
|
||||||
|
if (stream.getDeliveryUrl() != null) {
|
||||||
|
info.setUrl(api.createUrl(stream.getDeliveryUrl(), new HashMap<>(), new HashMap<>(), true));
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
public final ArrayList<MediaStream> getSelectableAudioStreams() {
|
||||||
|
return getSelectableStreams(MediaStreamType.AUDIO);
|
||||||
|
}
|
||||||
|
|
||||||
|
public final ArrayList<org.jellyfin.sdk.model.api.MediaStream> getSelectableStreams(MediaStreamType type) {
|
||||||
|
ArrayList<org.jellyfin.sdk.model.api.MediaStream> list = new ArrayList<org.jellyfin.sdk.model.api.MediaStream>();
|
||||||
|
|
||||||
|
for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) {
|
||||||
|
if (type == stream.getType()) {
|
||||||
|
list.add(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat
|
||||||
|
|
||||||
|
import org.jellyfin.apiclient.model.dlna.SubtitleDeliveryMethod
|
||||||
|
|
||||||
|
class SubtitleStreamInfo {
|
||||||
|
var url: String? = null
|
||||||
|
var name: String? = null
|
||||||
|
var format: String? = null
|
||||||
|
var displayTitle: String? = null
|
||||||
|
var index = 0
|
||||||
|
var deliveryMethod = SubtitleDeliveryMethod.Encode
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.jellyfin.androidtv.data.compat
|
||||||
|
|
||||||
|
class VideoOptions : AudioOptions() {
|
||||||
|
var audioStreamIndex: Int? = null
|
||||||
|
var subtitleStreamIndex: Int? = null
|
||||||
|
}
|
|
@ -0,0 +1,244 @@
|
||||||
|
package org.jellyfin.androidtv.data.eventhandling
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||||
|
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||||
|
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||||
|
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||||
|
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||||
|
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||||
|
import org.jellyfin.androidtv.ui.playback.setSubtitleIndex
|
||||||
|
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.sockets.subscribe
|
||||||
|
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommand
|
||||||
|
import org.jellyfin.sdk.api.sockets.subscribeGeneralCommands
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.GeneralCommandType
|
||||||
|
import org.jellyfin.sdk.model.api.LibraryChangedMessage
|
||||||
|
import org.jellyfin.sdk.model.api.LibraryUpdateInfo
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMessage
|
||||||
|
import org.jellyfin.sdk.model.api.PlaystateCommand
|
||||||
|
import org.jellyfin.sdk.model.api.PlaystateMessage
|
||||||
|
import org.jellyfin.sdk.model.extensions.get
|
||||||
|
import org.jellyfin.sdk.model.extensions.getValue
|
||||||
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class SocketHandler(
|
||||||
|
private val context: Context,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val dataRefreshService: DataRefreshService,
|
||||||
|
private val mediaManager: MediaManager,
|
||||||
|
private val playbackControllerContainer: PlaybackControllerContainer,
|
||||||
|
private val navigationRepository: NavigationRepository,
|
||||||
|
private val audioManager: AudioManager,
|
||||||
|
private val itemLauncher: ItemLauncher,
|
||||||
|
private val playbackHelper: PlaybackHelper,
|
||||||
|
) {
|
||||||
|
private val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
suspend fun updateSession() {
|
||||||
|
try {
|
||||||
|
api.sessionApi.postCapabilities(
|
||||||
|
playableMediaTypes = listOf(MediaType.VIDEO, MediaType.AUDIO),
|
||||||
|
supportsMediaControl = true,
|
||||||
|
supportedCommands = buildList {
|
||||||
|
add(GeneralCommandType.DISPLAY_CONTENT)
|
||||||
|
add(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||||
|
add(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||||
|
|
||||||
|
add(GeneralCommandType.DISPLAY_MESSAGE)
|
||||||
|
add(GeneralCommandType.SEND_STRING)
|
||||||
|
|
||||||
|
// Note: These are used in the PlaySessionSocketService
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !audioManager.isVolumeFixed) {
|
||||||
|
add(GeneralCommandType.VOLUME_UP)
|
||||||
|
add(GeneralCommandType.VOLUME_DOWN)
|
||||||
|
add(GeneralCommandType.SET_VOLUME)
|
||||||
|
|
||||||
|
add(GeneralCommandType.MUTE)
|
||||||
|
add(GeneralCommandType.UNMUTE)
|
||||||
|
add(GeneralCommandType.TOGGLE_MUTE)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to update capabilities")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
api.webSocket.apply {
|
||||||
|
// Library
|
||||||
|
subscribe<LibraryChangedMessage>()
|
||||||
|
.onEach { message -> message.data?.let(::onLibraryChanged) }
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// Media playback
|
||||||
|
subscribe<PlayMessage>()
|
||||||
|
.onEach { message -> onPlayMessage(message) }
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
subscribe<PlaystateMessage>()
|
||||||
|
.onEach { message -> onPlayStateMessage(message) }
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
subscribeGeneralCommand(GeneralCommandType.SET_SUBTITLE_STREAM_INDEX)
|
||||||
|
.onEach { message ->
|
||||||
|
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
playbackControllerContainer.playbackController?.setSubtitleIndex(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
subscribeGeneralCommand(GeneralCommandType.SET_AUDIO_STREAM_INDEX)
|
||||||
|
.onEach { message ->
|
||||||
|
val index = message["index"]?.toIntOrNull() ?: return@onEach
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
playbackControllerContainer.playbackController?.switchAudioStream(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
// General commands
|
||||||
|
subscribeGeneralCommand(GeneralCommandType.DISPLAY_CONTENT)
|
||||||
|
.onEach { message ->
|
||||||
|
val itemId by message
|
||||||
|
val itemType by message
|
||||||
|
|
||||||
|
val itemUuid = itemId?.toUUIDOrNull()
|
||||||
|
val itemKind = itemType?.let { type ->
|
||||||
|
BaseItemKind.entries.find { value ->
|
||||||
|
value.serialName.equals(type, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemUuid != null && itemKind != null) onDisplayContent(itemUuid, itemKind)
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
|
||||||
|
subscribeGeneralCommands(setOf(GeneralCommandType.DISPLAY_MESSAGE, GeneralCommandType.SEND_STRING))
|
||||||
|
.onEach { message ->
|
||||||
|
val header by message
|
||||||
|
val text by message
|
||||||
|
val string by message
|
||||||
|
|
||||||
|
onDisplayMessage(header, text ?: string)
|
||||||
|
}
|
||||||
|
.launchIn(coroutineScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onLibraryChanged(info: LibraryUpdateInfo) {
|
||||||
|
Timber.d(buildString {
|
||||||
|
appendLine("Library changed.")
|
||||||
|
appendLine("Added ${info.itemsAdded.size} items")
|
||||||
|
appendLine("Removed ${info.itemsRemoved.size} items")
|
||||||
|
appendLine("Updated ${info.itemsUpdated.size} items")
|
||||||
|
})
|
||||||
|
|
||||||
|
if (info.itemsAdded.any() || info.itemsRemoved.any())
|
||||||
|
dataRefreshService.lastLibraryChange = Instant.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPlayMessage(message: PlayMessage) {
|
||||||
|
val itemId = message.data?.itemIds?.firstOrNull() ?: return
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
playbackHelper.retrieveAndPlay(
|
||||||
|
itemId,
|
||||||
|
false,
|
||||||
|
message.data?.startPositionTicks,
|
||||||
|
context
|
||||||
|
)
|
||||||
|
}.onFailure { Timber.w(it, "Failed to start playback") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ComplexMethod")
|
||||||
|
private suspend fun onPlayStateMessage(message: PlaystateMessage) = withContext(Dispatchers.Main) {
|
||||||
|
Timber.i("Received PlayStateMessage with command ${message.data?.command}")
|
||||||
|
|
||||||
|
// Audio playback uses (Rewrite)MediaManager, (legacy) video playback uses playbackController
|
||||||
|
when {
|
||||||
|
mediaManager.hasAudioQueueItems() -> {
|
||||||
|
Timber.i("Ignoring PlayStateMessage: should be handled by PlaySessionSocketService")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaybackController
|
||||||
|
else -> {
|
||||||
|
val playbackController = playbackControllerContainer.playbackController
|
||||||
|
when (message.data?.command) {
|
||||||
|
PlaystateCommand.STOP -> playbackController?.endPlayback(true)
|
||||||
|
PlaystateCommand.PAUSE, PlaystateCommand.UNPAUSE, PlaystateCommand.PLAY_PAUSE -> playbackController?.playPause()
|
||||||
|
PlaystateCommand.NEXT_TRACK -> playbackController?.next()
|
||||||
|
PlaystateCommand.PREVIOUS_TRACK -> playbackController?.prev()
|
||||||
|
PlaystateCommand.SEEK -> playbackController?.seek(
|
||||||
|
(message.data?.seekPositionTicks ?: 0) / TICKS_TO_MS
|
||||||
|
)
|
||||||
|
|
||||||
|
PlaystateCommand.REWIND -> playbackController?.rewind()
|
||||||
|
PlaystateCommand.FAST_FORWARD -> playbackController?.fastForward()
|
||||||
|
|
||||||
|
null -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun onDisplayContent(itemId: UUID, itemKind: BaseItemKind) = withContext(Dispatchers.Main) {
|
||||||
|
val playbackController = playbackControllerContainer.playbackController
|
||||||
|
|
||||||
|
if (playbackController?.isPlaying == true || playbackController?.isPaused == true) {
|
||||||
|
Timber.i("Not launching $itemId: playback in progress")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.i("Launching $itemId")
|
||||||
|
|
||||||
|
when (itemKind) {
|
||||||
|
BaseItemKind.USER_VIEW,
|
||||||
|
BaseItemKind.COLLECTION_FOLDER -> {
|
||||||
|
val item by api.userLibraryApi.getItem(itemId = itemId)
|
||||||
|
itemLauncher.launchUserView(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> navigationRepository.navigate(Destinations.itemDetails(itemId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDisplayMessage(header: String?, text: String?) {
|
||||||
|
val toastMessage = buildString {
|
||||||
|
if (!header.isNullOrBlank()) append(header, ": ")
|
||||||
|
append(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking(Dispatchers.Main) {
|
||||||
|
Toast.makeText(context, toastMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val TICKS_TO_MS = 10000L
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.data.model
|
||||||
|
|
||||||
|
data class AppNotification(
|
||||||
|
val message: String,
|
||||||
|
val dismiss: () -> Unit,
|
||||||
|
val public: Boolean,
|
||||||
|
)
|
|
@ -0,0 +1,10 @@
|
||||||
|
package org.jellyfin.androidtv.data.model
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class ChapterItemInfo(
|
||||||
|
val itemId: UUID,
|
||||||
|
val name: String?,
|
||||||
|
val startPositionTicks: Long,
|
||||||
|
val imagePath: String?,
|
||||||
|
)
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.jellyfin.androidtv.data.model
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class DataRefreshService {
|
||||||
|
var lastDeletedItemId: UUID? = null
|
||||||
|
var lastPlayback: Instant? = null
|
||||||
|
var lastMoviePlayback: Instant? = null
|
||||||
|
var lastTvPlayback: Instant? = null
|
||||||
|
var lastLibraryChange: Instant? = null
|
||||||
|
var lastFavoriteUpdate: Instant? = null
|
||||||
|
var lastPlayedItem: BaseItemDto? = null
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package org.jellyfin.androidtv.data.model
|
||||||
|
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFilter
|
||||||
|
|
||||||
|
|
||||||
|
class FilterOptions {
|
||||||
|
var isFavoriteOnly = false
|
||||||
|
var isUnwatchedOnly = false
|
||||||
|
|
||||||
|
val filters: Set<ItemFilter>
|
||||||
|
get() = buildSet {
|
||||||
|
if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE)
|
||||||
|
if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package org.jellyfin.androidtv.data.model
|
||||||
|
|
||||||
|
data class InfoItem @JvmOverloads constructor(
|
||||||
|
val label: String = "",
|
||||||
|
val value: String = "",
|
||||||
|
)
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.data.querying;
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class GetAdditionalPartsRequest(
|
||||||
|
val itemId: UUID,
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
package org.jellyfin.androidtv.data.querying
|
||||||
|
|
||||||
|
data object GetSeriesTimersRequest
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.data.querying
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class GetSpecialsRequest(
|
||||||
|
val itemId: UUID,
|
||||||
|
)
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.data.querying
|
||||||
|
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class GetTrailersRequest(
|
||||||
|
val itemId: UUID,
|
||||||
|
)
|
|
@ -0,0 +1,3 @@
|
||||||
|
package org.jellyfin.androidtv.data.querying
|
||||||
|
|
||||||
|
data object GetUserViewsRequest
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.jellyfin.androidtv.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.jellyfin.androidtv.constant.CustomMessage
|
||||||
|
|
||||||
|
interface CustomMessageRepository {
|
||||||
|
val message: StateFlow<CustomMessage?>
|
||||||
|
fun pushMessage(message: CustomMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomMessageRepositoryImpl : CustomMessageRepository {
|
||||||
|
private val _message = MutableStateFlow<CustomMessage?>(null)
|
||||||
|
override val message get() = _message.asStateFlow()
|
||||||
|
|
||||||
|
override fun pushMessage(message: CustomMessage) {
|
||||||
|
// Make sure to re-emit the same message if requested
|
||||||
|
if (_message.value == message) _message.value = null
|
||||||
|
|
||||||
|
_message.value = message
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package org.jellyfin.androidtv.data.repository
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.UserItemDataDto
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
interface ItemMutationRepository {
|
||||||
|
suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto
|
||||||
|
suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto
|
||||||
|
}
|
||||||
|
|
||||||
|
class ItemMutationRepositoryImpl(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val dataRefreshService: DataRefreshService,
|
||||||
|
) : ItemMutationRepository {
|
||||||
|
override suspend fun setFavorite(item: UUID, favorite: Boolean): UserItemDataDto {
|
||||||
|
val response by when {
|
||||||
|
favorite -> api.userLibraryApi.markFavoriteItem(itemId = item)
|
||||||
|
else -> api.userLibraryApi.unmarkFavoriteItem(itemId = item)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataRefreshService.lastFavoriteUpdate = Instant.now()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPlayed(item: UUID, played: Boolean): UserItemDataDto {
|
||||||
|
val response by when {
|
||||||
|
played -> api.playStateApi.markPlayedItem(itemId = item)
|
||||||
|
else -> api.playStateApi.markUnplayedItem(itemId = item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
package org.jellyfin.androidtv.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.androidtv.auth.model.Server
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||||
|
import org.jellyfin.androidtv.data.model.AppNotification
|
||||||
|
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||||
|
import org.jellyfin.androidtv.util.isTvDevice
|
||||||
|
import org.jellyfin.sdk.model.ServerVersion
|
||||||
|
|
||||||
|
interface NotificationsRepository {
|
||||||
|
val notifications: StateFlow<List<AppNotification>>
|
||||||
|
|
||||||
|
fun dismissNotification(item: AppNotification)
|
||||||
|
fun addDefaultNotifications()
|
||||||
|
fun updateServerNotifications(server: Server?)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationsRepositoryImpl(
|
||||||
|
private val context: Context,
|
||||||
|
private val systemPreferences: SystemPreferences,
|
||||||
|
) : NotificationsRepository {
|
||||||
|
override val notifications = MutableStateFlow(emptyList<AppNotification>())
|
||||||
|
|
||||||
|
override fun dismissNotification(item: AppNotification) {
|
||||||
|
notifications.value = notifications.value.filter { it != item }
|
||||||
|
item.dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addDefaultNotifications() {
|
||||||
|
addUiModeNotification()
|
||||||
|
addBetaNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addNotification(
|
||||||
|
message: String,
|
||||||
|
public: Boolean = false,
|
||||||
|
dismiss: () -> Unit = {}
|
||||||
|
): AppNotification {
|
||||||
|
val notification = AppNotification(message, dismiss, public)
|
||||||
|
notifications.value += notification
|
||||||
|
return notification
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeNotification(notification: AppNotification) {
|
||||||
|
notifications.value -= notification
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addUiModeNotification() {
|
||||||
|
val disableUiModeWarning = systemPreferences[SystemPreferences.disableUiModeWarning]
|
||||||
|
|
||||||
|
if (!context.isTvDevice() && !disableUiModeWarning) {
|
||||||
|
addNotification(
|
||||||
|
context.getString(R.string.app_notification_uimode_invalid),
|
||||||
|
public = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addBetaNotification() {
|
||||||
|
val dismissedVersion = systemPreferences[SystemPreferences.dismissedBetaNotificationVersion]
|
||||||
|
val currentVersion = BuildConfig.VERSION_NAME
|
||||||
|
val isBeta = currentVersion.lowercase().contains("beta")
|
||||||
|
|
||||||
|
if (isBeta && currentVersion != dismissedVersion) {
|
||||||
|
addNotification(context.getString(R.string.app_notification_beta, currentVersion)) {
|
||||||
|
systemPreferences[SystemPreferences.dismissedBetaNotificationVersion] =
|
||||||
|
currentVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update server notification
|
||||||
|
private var _updateServerNotification: AppNotification? = null
|
||||||
|
override fun updateServerNotifications(server: Server?) {
|
||||||
|
// Remove current update notification
|
||||||
|
_updateServerNotification?.let(::removeNotification)
|
||||||
|
|
||||||
|
val currentServerVersion = server?.version?.let(ServerVersion::fromString) ?: return
|
||||||
|
if (currentServerVersion < ServerRepository.upcomingMinimumServerVersion) {
|
||||||
|
_updateServerNotification =
|
||||||
|
addNotification(
|
||||||
|
message = context.getString(
|
||||||
|
R.string.app_notification_update_soon,
|
||||||
|
currentServerVersion,
|
||||||
|
ServerRepository.upcomingMinimumServerVersion
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package org.jellyfin.androidtv.data.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
|
|
||||||
|
interface UserViewsRepository {
|
||||||
|
val views: Flow<Collection<BaseItemDto>>
|
||||||
|
|
||||||
|
fun isSupported(collectionType: CollectionType?): Boolean
|
||||||
|
fun allowViewSelection(collectionType: CollectionType?): Boolean
|
||||||
|
fun allowGridView(collectionType: CollectionType?): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserViewsRepositoryImpl(
|
||||||
|
private val api: ApiClient,
|
||||||
|
) : UserViewsRepository {
|
||||||
|
override val views = flow {
|
||||||
|
val views by api.userViewsApi.getUserViews()
|
||||||
|
val filteredViews = views.items
|
||||||
|
.filter { isSupported(it.collectionType) }
|
||||||
|
emit(filteredViews)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSupported(collectionType: CollectionType?) = collectionType !in unsupportedCollectionTypes
|
||||||
|
override fun allowViewSelection(collectionType: CollectionType?) = collectionType !in disallowViewSelectionCollectionTypes
|
||||||
|
override fun allowGridView(collectionType: CollectionType?) = collectionType !in disallowGridViewCollectionTypes
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private val unsupportedCollectionTypes = arrayOf(
|
||||||
|
CollectionType.BOOKS,
|
||||||
|
CollectionType.FOLDERS
|
||||||
|
)
|
||||||
|
|
||||||
|
private val disallowViewSelectionCollectionTypes = arrayOf(
|
||||||
|
CollectionType.LIVETV,
|
||||||
|
CollectionType.MUSIC,
|
||||||
|
CollectionType.PHOTOS,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val disallowGridViewCollectionTypes = arrayOf(
|
||||||
|
CollectionType.LIVETV,
|
||||||
|
CollectionType.MUSIC
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,183 @@
|
||||||
|
package org.jellyfin.androidtv.data.service
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.ui.graphics.ImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.MainScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.androidtv.auth.model.Server
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
class BackgroundService(
|
||||||
|
private val context: Context,
|
||||||
|
private val jellyfin: Jellyfin,
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val SLIDESHOW_DURATION = 30.seconds
|
||||||
|
val TRANSITION_DURATION = 800.milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Async
|
||||||
|
private val scope = MainScope()
|
||||||
|
private var loadBackgroundsJob: Job? = null
|
||||||
|
private var updateBackgroundTimerJob: Job? = null
|
||||||
|
private var lastBackgroundTimerUpdate = 0L
|
||||||
|
|
||||||
|
// Current background data
|
||||||
|
private var _backgrounds = emptyList<ImageBitmap>()
|
||||||
|
private var _currentIndex = 0
|
||||||
|
private var _currentBackground = MutableStateFlow<ImageBitmap?>(null)
|
||||||
|
private var _blurBackground = MutableStateFlow(false)
|
||||||
|
private var _enabled = MutableStateFlow(true)
|
||||||
|
val currentBackground get() = _currentBackground.asStateFlow()
|
||||||
|
val blurBackground get() = _blurBackground.asStateFlow()
|
||||||
|
val enabled get() = _enabled.asStateFlow()
|
||||||
|
|
||||||
|
// Helper function for [setBackground]
|
||||||
|
private fun List<String>?.getUrls(itemId: UUID?): List<String> {
|
||||||
|
// Check for nullability
|
||||||
|
if (itemId == null || isNullOrEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return mapIndexed { index, tag ->
|
||||||
|
api.imageApi.getItemImageUrl(
|
||||||
|
itemId = itemId,
|
||||||
|
imageType = ImageType.BACKDROP,
|
||||||
|
tag = tag,
|
||||||
|
imageIndex = index,
|
||||||
|
fillWidth = context.resources.displayMetrics.widthPixels,
|
||||||
|
fillHeight = context.resources.displayMetrics.heightPixels,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use all available backdrops from [baseItem] as background.
|
||||||
|
*/
|
||||||
|
fun setBackground(baseItem: BaseItemDto?) {
|
||||||
|
// Check if item is set and backgrounds are enabled
|
||||||
|
if (baseItem == null || !userPreferences[UserPreferences.backdropEnabled])
|
||||||
|
return clearBackgrounds()
|
||||||
|
|
||||||
|
// Enable blur for backdrops
|
||||||
|
_blurBackground.value = true
|
||||||
|
|
||||||
|
// Get all backdrop urls
|
||||||
|
val itemBackdropUrls = baseItem.backdropImageTags.getUrls(baseItem.id)
|
||||||
|
val parentBackdropUrls = baseItem.parentBackdropImageTags.getUrls(baseItem.parentBackdropItemId)
|
||||||
|
val backdropUrls = itemBackdropUrls.union(parentBackdropUrls)
|
||||||
|
|
||||||
|
loadBackgrounds(backdropUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use splashscreen from [server] as background.
|
||||||
|
*/
|
||||||
|
fun setBackground(server: Server) {
|
||||||
|
// Check if item is set and backgrounds are enabled
|
||||||
|
if (!userPreferences[UserPreferences.backdropEnabled])
|
||||||
|
return clearBackgrounds()
|
||||||
|
|
||||||
|
// Check if splashscreen is enabled in (cached) branding options
|
||||||
|
if (!server.splashscreenEnabled)
|
||||||
|
return clearBackgrounds()
|
||||||
|
|
||||||
|
// Disable blur on splashscreen
|
||||||
|
_blurBackground.value = false
|
||||||
|
|
||||||
|
// Manually grab the backdrop URL
|
||||||
|
val api = jellyfin.createApi(baseUrl = server.address)
|
||||||
|
val splashscreenUrl = api.imageApi.getSplashscreenUrl()
|
||||||
|
|
||||||
|
loadBackgrounds(setOf(splashscreenUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBackgrounds(backdropUrls: Set<String>) {
|
||||||
|
if (backdropUrls.isEmpty()) return clearBackgrounds()
|
||||||
|
|
||||||
|
// Re-enable backgrounds if disabled
|
||||||
|
_enabled.value = true
|
||||||
|
|
||||||
|
// Cancel current loading job
|
||||||
|
loadBackgroundsJob?.cancel()
|
||||||
|
loadBackgroundsJob = scope.launch(Dispatchers.IO) {
|
||||||
|
_backgrounds = backdropUrls.mapNotNull { url ->
|
||||||
|
imageLoader.execute(
|
||||||
|
request = ImageRequest.Builder(context).data(url).build()
|
||||||
|
).image?.toBitmap()?.asImageBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to first background
|
||||||
|
_currentIndex = 0
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBackgrounds() {
|
||||||
|
loadBackgroundsJob?.cancel()
|
||||||
|
|
||||||
|
// Re-enable backgrounds if disabled
|
||||||
|
_enabled.value = true
|
||||||
|
|
||||||
|
if (_backgrounds.isEmpty()) return
|
||||||
|
|
||||||
|
_backgrounds = emptyList()
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the showing of backgrounds until any function manipulating the backgrounds is called.
|
||||||
|
*/
|
||||||
|
fun disable() {
|
||||||
|
_enabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun update() {
|
||||||
|
val now = Instant.now().toEpochMilli()
|
||||||
|
if (lastBackgroundTimerUpdate > now - TRANSITION_DURATION.inWholeMilliseconds)
|
||||||
|
return setTimer((lastBackgroundTimerUpdate - now).milliseconds + TRANSITION_DURATION, false)
|
||||||
|
|
||||||
|
lastBackgroundTimerUpdate = now
|
||||||
|
|
||||||
|
// Get next background to show
|
||||||
|
if (_currentIndex >= _backgrounds.size) _currentIndex = 0
|
||||||
|
|
||||||
|
// Set background
|
||||||
|
_currentBackground.value = _backgrounds.getOrNull(_currentIndex)
|
||||||
|
|
||||||
|
// Set timer for next background
|
||||||
|
if (_backgrounds.size > 1) setTimer()
|
||||||
|
else updateBackgroundTimerJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setTimer(updateDelay: Duration = SLIDESHOW_DURATION, increaseIndex: Boolean = true) {
|
||||||
|
updateBackgroundTimerJob?.cancel()
|
||||||
|
updateBackgroundTimerJob = scope.launch {
|
||||||
|
delay(updateDelay)
|
||||||
|
|
||||||
|
if (increaseIndex) _currentIndex++
|
||||||
|
|
||||||
|
update()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
17
app/src/main/java/org/jellyfin/androidtv/di/AndroidModule.kt
Normal file
17
app/src/main/java/org/jellyfin/androidtv/di/AndroidModule.kt
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import android.app.UiModeManager
|
||||||
|
import android.media.AudioManager
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import org.koin.android.ext.koin.androidApplication
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides DI for Android system components
|
||||||
|
*/
|
||||||
|
val androidModule = module {
|
||||||
|
factory { androidApplication().getSystemService<UiModeManager>()!! }
|
||||||
|
factory { androidApplication().getSystemService<AudioManager>()!! }
|
||||||
|
factory { WorkManager.getInstance(get()) }
|
||||||
|
}
|
149
app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Normal file
149
app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.gif.AnimatedImageDecoder
|
||||||
|
import coil3.gif.GifDecoder
|
||||||
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.serviceLoaderEnabled
|
||||||
|
import coil3.svg.SvgDecoder
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.UserRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.UserRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.data.eventhandling.SocketHandler
|
||||||
|
import org.jellyfin.androidtv.data.model.DataRefreshService
|
||||||
|
import org.jellyfin.androidtv.data.repository.CustomMessageRepository
|
||||||
|
import org.jellyfin.androidtv.data.repository.CustomMessageRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.data.repository.ItemMutationRepository
|
||||||
|
import org.jellyfin.androidtv.data.repository.ItemMutationRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.data.repository.NotificationsRepository
|
||||||
|
import org.jellyfin.androidtv.data.repository.NotificationsRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||||
|
import org.jellyfin.androidtv.data.repository.UserViewsRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.data.service.BackgroundService
|
||||||
|
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.ScreensaverViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.itemhandling.ItemLauncher
|
||||||
|
import org.jellyfin.androidtv.ui.navigation.Destinations
|
||||||
|
import org.jellyfin.androidtv.ui.navigation.NavigationRepository
|
||||||
|
import org.jellyfin.androidtv.ui.navigation.NavigationRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.ui.picture.PictureViewerViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.playback.PlaybackControllerContainer
|
||||||
|
import org.jellyfin.androidtv.ui.playback.nextup.NextUpViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepository
|
||||||
|
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.ui.search.SearchFragmentDelegate
|
||||||
|
import org.jellyfin.androidtv.ui.search.SearchRepository
|
||||||
|
import org.jellyfin.androidtv.ui.search.SearchRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.ui.search.SearchViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.startup.ServerAddViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.startup.StartupViewModel
|
||||||
|
import org.jellyfin.androidtv.ui.startup.UserLoginViewModel
|
||||||
|
import org.jellyfin.androidtv.util.KeyProcessor
|
||||||
|
import org.jellyfin.androidtv.util.MarkdownRenderer
|
||||||
|
import org.jellyfin.androidtv.util.PlaybackHelper
|
||||||
|
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
|
||||||
|
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
|
||||||
|
import org.jellyfin.androidtv.util.sdk.legacy
|
||||||
|
import org.jellyfin.apiclient.AppInfo
|
||||||
|
import org.jellyfin.apiclient.android
|
||||||
|
import org.jellyfin.apiclient.logging.AndroidLogger
|
||||||
|
import org.jellyfin.sdk.android.androidDevice
|
||||||
|
import org.jellyfin.sdk.createJellyfin
|
||||||
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import org.koin.android.ext.koin.androidApplication
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.module.dsl.viewModel
|
||||||
|
import org.koin.core.qualifier.named
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.jellyfin.apiclient.Jellyfin as JellyfinApiClient
|
||||||
|
import org.jellyfin.sdk.Jellyfin as JellyfinSdk
|
||||||
|
|
||||||
|
val defaultDeviceInfo = named("defaultDeviceInfo")
|
||||||
|
|
||||||
|
val appModule = module {
|
||||||
|
// New SDK
|
||||||
|
single(defaultDeviceInfo) { androidDevice(get()) }
|
||||||
|
single {
|
||||||
|
createJellyfin {
|
||||||
|
context = androidContext()
|
||||||
|
|
||||||
|
// Add client info
|
||||||
|
clientInfo = ClientInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||||
|
deviceInfo = get(defaultDeviceInfo)
|
||||||
|
|
||||||
|
// Change server version
|
||||||
|
minimumServerVersion = ServerRepository.minimumServerVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
single {
|
||||||
|
// Create an empty API instance, the actual values are set by the SessionRepository
|
||||||
|
get<JellyfinSdk>().createApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
single { SocketHandler(get(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
// Old apiclient
|
||||||
|
single {
|
||||||
|
JellyfinApiClient {
|
||||||
|
appInfo = AppInfo("Android TV", BuildConfig.VERSION_NAME)
|
||||||
|
logger = AndroidLogger()
|
||||||
|
android(androidApplication())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
single {
|
||||||
|
get<JellyfinApiClient>().createApi(
|
||||||
|
device = get<DeviceInfo>(defaultDeviceInfo).legacy()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coil (images)
|
||||||
|
single {
|
||||||
|
ImageLoader.Builder(androidContext()).apply {
|
||||||
|
serviceLoaderEnabled(false)
|
||||||
|
components {
|
||||||
|
add(OkHttpNetworkFetcherFactory())
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
|
||||||
|
else add(GifDecoder.Factory())
|
||||||
|
add(SvgDecoder.Factory())
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non API related
|
||||||
|
single { DataRefreshService() }
|
||||||
|
single { PlaybackControllerContainer() }
|
||||||
|
|
||||||
|
single<UserRepository> { UserRepositoryImpl() }
|
||||||
|
single<UserViewsRepository> { UserViewsRepositoryImpl(get()) }
|
||||||
|
single<NotificationsRepository> { NotificationsRepositoryImpl(get(), get()) }
|
||||||
|
single<ItemMutationRepository> { ItemMutationRepositoryImpl(get(), get()) }
|
||||||
|
single<CustomMessageRepository> { CustomMessageRepositoryImpl() }
|
||||||
|
single<NavigationRepository> { NavigationRepositoryImpl(Destinations.home) }
|
||||||
|
single<SearchRepository> { SearchRepositoryImpl(get()) }
|
||||||
|
single<MediaSegmentRepository> { MediaSegmentRepositoryImpl(get(), get()) }
|
||||||
|
|
||||||
|
viewModel { StartupViewModel(get(), get(), get(), get()) }
|
||||||
|
viewModel { UserLoginViewModel(get(), get(), get(), get(defaultDeviceInfo)) }
|
||||||
|
viewModel { ServerAddViewModel(get()) }
|
||||||
|
viewModel { NextUpViewModel(get(), get(), get(), get()) }
|
||||||
|
viewModel { PictureViewerViewModel(get()) }
|
||||||
|
viewModel { ScreensaverViewModel(get()) }
|
||||||
|
viewModel { SearchViewModel(get()) }
|
||||||
|
viewModel { DreamViewModel(get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
single { BackgroundService(get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
single { MarkdownRenderer(get()) }
|
||||||
|
single { ItemLauncher() }
|
||||||
|
single { KeyProcessor() }
|
||||||
|
single { ReportingHelper(get(), get()) }
|
||||||
|
single<PlaybackHelper> { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) }
|
||||||
|
}
|
32
app/src/main/java/org/jellyfin/androidtv/di/AuthModule.kt
Normal file
32
app/src/main/java/org/jellyfin/androidtv/di/AuthModule.kt
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.auth.AccountManagerMigration
|
||||||
|
import org.jellyfin.androidtv.auth.apiclient.ApiBinder
|
||||||
|
import org.jellyfin.androidtv.auth.repository.AuthenticationRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.AuthenticationRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerUserRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.ServerUserRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.auth.repository.SessionRepository
|
||||||
|
import org.jellyfin.androidtv.auth.repository.SessionRepositoryImpl
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationPreferences
|
||||||
|
import org.jellyfin.androidtv.auth.store.AuthenticationStore
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val authModule = module {
|
||||||
|
single { AccountManagerMigration(get()) }
|
||||||
|
single { AuthenticationStore(get(), get()) }
|
||||||
|
single { AuthenticationPreferences(get()) }
|
||||||
|
|
||||||
|
single<AuthenticationRepository> {
|
||||||
|
AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo))
|
||||||
|
}
|
||||||
|
single<ServerRepository> { ServerRepositoryImpl(get(), get()) }
|
||||||
|
single<ServerUserRepository> { ServerUserRepositoryImpl(get(), get()) }
|
||||||
|
single<SessionRepository> {
|
||||||
|
SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get())
|
||||||
|
}
|
||||||
|
|
||||||
|
single { ApiBinder(get(), get()) }
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.startup.Initializer
|
||||||
|
import org.jellyfin.androidtv.LogInitializer
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.KoinApplication
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
|
||||||
|
class KoinInitializer : Initializer<KoinApplication> {
|
||||||
|
override fun create(context: Context): KoinApplication = startKoin {
|
||||||
|
androidContext(context)
|
||||||
|
|
||||||
|
modules(
|
||||||
|
androidModule,
|
||||||
|
appModule,
|
||||||
|
authModule,
|
||||||
|
playbackModule,
|
||||||
|
preferenceModule,
|
||||||
|
utilsModule,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dependencies() = listOf(LogInitializer::class.java)
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,86 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||||
|
import org.jellyfin.androidtv.ui.browsing.MainActivity
|
||||||
|
import org.jellyfin.androidtv.ui.playback.GarbagePlaybackLauncher
|
||||||
|
import org.jellyfin.androidtv.ui.playback.MediaManager
|
||||||
|
import org.jellyfin.androidtv.ui.playback.RewritePlaybackLauncher
|
||||||
|
import org.jellyfin.androidtv.ui.playback.VideoQueueManager
|
||||||
|
import org.jellyfin.androidtv.ui.playback.rewrite.RewriteMediaManager
|
||||||
|
import org.jellyfin.playback.core.playbackManager
|
||||||
|
import org.jellyfin.playback.jellyfin.jellyfinPlugin
|
||||||
|
import org.jellyfin.playback.media3.exoplayer.ExoPlayerOptions
|
||||||
|
import org.jellyfin.playback.media3.exoplayer.exoPlayerPlugin
|
||||||
|
import org.jellyfin.playback.media3.session.MediaSessionOptions
|
||||||
|
import org.jellyfin.playback.media3.session.media3SessionPlugin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.scope.Scope
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import org.jellyfin.androidtv.ui.playback.PlaybackManager as LegacyPlaybackManager
|
||||||
|
|
||||||
|
val playbackModule = module {
|
||||||
|
single { LegacyPlaybackManager(get()) }
|
||||||
|
single { VideoQueueManager() }
|
||||||
|
single<MediaManager> { RewriteMediaManager(get(), get(), get(), get()) }
|
||||||
|
|
||||||
|
factory {
|
||||||
|
val preferences = get<UserPreferences>()
|
||||||
|
val useRewrite = preferences[UserPreferences.playbackRewriteVideoEnabled] && BuildConfig.DEVELOPMENT
|
||||||
|
|
||||||
|
if (useRewrite) RewritePlaybackLauncher()
|
||||||
|
else GarbagePlaybackLauncher(get())
|
||||||
|
}
|
||||||
|
|
||||||
|
single { createPlaybackManager() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun Scope.createPlaybackManager() = playbackManager(androidContext()) {
|
||||||
|
val activityIntent = Intent(get(), MainActivity::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getActivity(get(), 0, activityIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
|
||||||
|
val notificationChannelId = "session"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
notificationChannelId,
|
||||||
|
notificationChannelId,
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
NotificationManagerCompat.from(get()).createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
val userPreferences = get<UserPreferences>()
|
||||||
|
val api = get<ApiClient>()
|
||||||
|
val exoPlayerOptions = ExoPlayerOptions(
|
||||||
|
httpConnectTimeout = api.httpClientOptions.connectTimeout,
|
||||||
|
httpReadTimeout = api.httpClientOptions.requestTimeout,
|
||||||
|
preferFfmpeg = userPreferences[UserPreferences.preferExoPlayerFfmpeg],
|
||||||
|
enableDebugLogging = userPreferences[UserPreferences.debuggingEnabled],
|
||||||
|
)
|
||||||
|
install(exoPlayerPlugin(get(), exoPlayerOptions))
|
||||||
|
|
||||||
|
val mediaSessionOptions = MediaSessionOptions(
|
||||||
|
channelId = notificationChannelId,
|
||||||
|
notificationId = 1,
|
||||||
|
iconSmall = R.drawable.app_icon_foreground,
|
||||||
|
openIntent = pendingIntent,
|
||||||
|
)
|
||||||
|
install(media3SessionPlugin(get(), mediaSessionOptions))
|
||||||
|
|
||||||
|
install(jellyfinPlugin(get()))
|
||||||
|
|
||||||
|
// Options
|
||||||
|
val userSettingPreferences = get<UserSettingPreferences>()
|
||||||
|
defaultRewindAmount = { userSettingPreferences[UserSettingPreferences.skipBackLength].milliseconds }
|
||||||
|
defaultFastForwardAmount = { userSettingPreferences[UserSettingPreferences.skipForwardLength].milliseconds }
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.preference.LiveTvPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.PreferencesRepository
|
||||||
|
import org.jellyfin.androidtv.preference.SystemPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.TelemetryPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.UserSettingPreferences
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val preferenceModule = module {
|
||||||
|
single { PreferencesRepository(get(), get(), get()) }
|
||||||
|
|
||||||
|
single { LiveTvPreferences(get()) }
|
||||||
|
single { UserSettingPreferences(get()) }
|
||||||
|
single { UserPreferences(get()) }
|
||||||
|
single { SystemPreferences(get()) }
|
||||||
|
single { TelemetryPreferences(get()) }
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.jellyfin.androidtv.di
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.util.ImageHelper
|
||||||
|
import org.koin.dsl.module
|
||||||
|
|
||||||
|
val utilsModule = module {
|
||||||
|
single { ImageHelper(get()) }
|
||||||
|
}
|
|
@ -0,0 +1,473 @@
|
||||||
|
package org.jellyfin.androidtv.integration
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.tvprovider.media.tv.Channel
|
||||||
|
import androidx.tvprovider.media.tv.ChannelLogoUtils
|
||||||
|
import androidx.tvprovider.media.tv.PreviewProgram
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat
|
||||||
|
import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms
|
||||||
|
import androidx.tvprovider.media.tv.WatchNextProgram
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.androidtv.data.repository.UserViewsRepository
|
||||||
|
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.androidtv.ui.startup.StartupActivity
|
||||||
|
import org.jellyfin.androidtv.util.ImageHelper
|
||||||
|
import org.jellyfin.androidtv.util.dp
|
||||||
|
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||||
|
import org.jellyfin.androidtv.util.stripHtml
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.exception.TimeoutException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageFormat
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.jellyfin.sdk.model.api.MediaType
|
||||||
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages channels on the android tv home screen.
|
||||||
|
*
|
||||||
|
* More info: https://developer.android.com/training/tv/discovery/recommendations-channel.
|
||||||
|
*/
|
||||||
|
class LeanbackChannelWorker(
|
||||||
|
private val context: Context,
|
||||||
|
workerParams: WorkerParameters,
|
||||||
|
) : CoroutineWorker(context, workerParams), KoinComponent {
|
||||||
|
companion object {
|
||||||
|
const val PERIODIC_UPDATE_REQUEST_NAME = "LeanbackChannelPeriodicUpdateRequest"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val api by inject<ApiClient>()
|
||||||
|
private val userPreferences by inject<UserPreferences>()
|
||||||
|
private val userViewsRepository by inject<UserViewsRepository>()
|
||||||
|
private val imageHelper by inject<ImageHelper>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the app can use Leanback features and is API level 26 or higher.
|
||||||
|
*/
|
||||||
|
private val isSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||||
|
// Check for leanback support
|
||||||
|
context.packageManager.hasSystemFeature("android.software.leanback")
|
||||||
|
// Check for "android.media.tv" provider to workaround a false-positive in the previous check
|
||||||
|
&& context.packageManager.resolveContentProvider(TvContractCompat.AUTHORITY, 0) != null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all channels for the currently authenticated user.
|
||||||
|
*/
|
||||||
|
override suspend fun doWork(): Result = when {
|
||||||
|
// Fail when not supported
|
||||||
|
!isSupported -> Result.failure()
|
||||||
|
// Retry later if no authenticated user is found
|
||||||
|
!api.isUsable -> Result.retry()
|
||||||
|
else -> try {
|
||||||
|
// Get next up episodes
|
||||||
|
val (resumeItems, nextUpItems) = getNextUpItems()
|
||||||
|
// Get latest media
|
||||||
|
val (latestEpisodes, latestMovies, latestMedia) = getLatestMedia()
|
||||||
|
val myMedia = getMyMedia()
|
||||||
|
// Delete current items from the channels
|
||||||
|
context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null)
|
||||||
|
|
||||||
|
// Get channel URIs
|
||||||
|
val latestMediaChannel = getChannelUri(
|
||||||
|
"latest_media", Channel.Builder()
|
||||||
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(context.getString(R.string.home_section_latest_media))
|
||||||
|
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||||
|
.build(),
|
||||||
|
default = true
|
||||||
|
)
|
||||||
|
val myMediaChannel = getChannelUri(
|
||||||
|
"my_media", Channel.Builder()
|
||||||
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(context.getString(R.string.lbl_my_media))
|
||||||
|
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val nextUpChannel = getChannelUri(
|
||||||
|
"next_up", Channel.Builder()
|
||||||
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(context.getString(R.string.lbl_next_up))
|
||||||
|
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val latestMoviesChannel = getChannelUri(
|
||||||
|
"latest_movies", Channel.Builder()
|
||||||
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(context.getString(R.string.lbl_movies))
|
||||||
|
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val latestEpisodesChannel = getChannelUri(
|
||||||
|
"latest_episodes", Channel.Builder()
|
||||||
|
.setType(TvContractCompat.Channels.TYPE_PREVIEW)
|
||||||
|
.setDisplayName(context.getString(R.string.lbl_new_episodes))
|
||||||
|
.setAppLinkIntent(Intent(context, StartupActivity::class.java))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||||
|
|
||||||
|
// Add new items
|
||||||
|
arrayOf(
|
||||||
|
nextUpItems to nextUpChannel,
|
||||||
|
latestMedia to latestMediaChannel,
|
||||||
|
latestMovies to latestMoviesChannel,
|
||||||
|
latestEpisodes to latestEpisodesChannel,
|
||||||
|
myMedia to myMediaChannel,
|
||||||
|
).forEach { (items, channel) ->
|
||||||
|
Timber.d("Updating channel %s", channel)
|
||||||
|
items.map { item ->
|
||||||
|
createPreviewProgram(
|
||||||
|
channel,
|
||||||
|
item,
|
||||||
|
preferParentThumb
|
||||||
|
)
|
||||||
|
}.let {
|
||||||
|
context.contentResolver.bulkInsert(
|
||||||
|
TvContractCompat.PreviewPrograms.CONTENT_URI,
|
||||||
|
it.toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateWatchNext(resumeItems + nextUpItems)
|
||||||
|
|
||||||
|
// Success!
|
||||||
|
Result.success()
|
||||||
|
} catch (err: TimeoutException) {
|
||||||
|
Timber.w(err, "Server unreachable, trying again later")
|
||||||
|
|
||||||
|
Result.retry()
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "SDK error, trying again later")
|
||||||
|
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the uri for a channel or create it if it doesn't exist. Uses the [settings] parameter to
|
||||||
|
* update or create the channel. The [name] parameter is used to store the id and should be
|
||||||
|
* unique.
|
||||||
|
*/
|
||||||
|
private fun getChannelUri(name: String, settings: Channel, default: Boolean = false): Uri {
|
||||||
|
val store = context.getSharedPreferences("leanback_channels", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val uri = if (store.contains(name)) {
|
||||||
|
// Retrieve uri and update content resolver
|
||||||
|
Uri.parse(store.getString(name, null)).also { uri ->
|
||||||
|
context.contentResolver.update(uri, settings.toContentValues(), null, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new channel and save uri
|
||||||
|
context.contentResolver.insert(
|
||||||
|
TvContractCompat.Channels.CONTENT_URI,
|
||||||
|
settings.toContentValues()
|
||||||
|
)!!.also { uri ->
|
||||||
|
store.edit().putString(name, uri.toString()).apply()
|
||||||
|
if (default) {
|
||||||
|
// Set as default row to display (we can request one row to automatically be added to the home screen)
|
||||||
|
TvContractCompat.requestChannelBrowsable(context, ContentUris.parseId(uri))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update logo
|
||||||
|
ResourcesCompat.getDrawable(context.resources, R.mipmap.app_icon, context.theme)?.let {
|
||||||
|
ChannelLogoUtils.storeChannelLogo(
|
||||||
|
context,
|
||||||
|
ContentUris.parseId(uri),
|
||||||
|
it.toBitmap(80.dp(context), 80.dp(context))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return uri
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the "my media" row with current media libraries.
|
||||||
|
*/
|
||||||
|
@Suppress("RestrictedApi")
|
||||||
|
private suspend fun getMyMedia(): List<BaseItemDto> {
|
||||||
|
val response by api.userViewsApi.getUserViews(includeHidden = false)
|
||||||
|
|
||||||
|
// Add new items
|
||||||
|
return response.items
|
||||||
|
.filter { userViewsRepository.isSupported(it.collectionType) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the poster art for an item. Uses the [preferParentThumb] parameter to fetch the series
|
||||||
|
* image when preferred.
|
||||||
|
*/
|
||||||
|
private fun BaseItemDto.getPosterArtImageUrl(
|
||||||
|
preferParentThumb: Boolean
|
||||||
|
): Uri = when {
|
||||||
|
type == BaseItemKind.MOVIE || type == BaseItemKind.SERIES -> api.imageApi.getItemImageUrl(
|
||||||
|
itemId = id,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
width = 106.dp(context),
|
||||||
|
height = 153.dp(context),
|
||||||
|
tag = imageTags?.get(ImageType.PRIMARY),
|
||||||
|
)
|
||||||
|
|
||||||
|
(preferParentThumb || imageTags?.contains(ImageType.PRIMARY) != true) && parentThumbItemId != null -> api.imageApi.getItemImageUrl(
|
||||||
|
itemId = parentThumbItemId!!,
|
||||||
|
imageType = ImageType.THUMB,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
width = 272.dp(context),
|
||||||
|
height = 153.dp(context),
|
||||||
|
tag = imageTags?.get(ImageType.THUMB),
|
||||||
|
)
|
||||||
|
|
||||||
|
imageTags?.containsKey(ImageType.PRIMARY) == true -> api.imageApi.getItemImageUrl(
|
||||||
|
itemId = id,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
width = 272.dp(context),
|
||||||
|
height = 153.dp(context),
|
||||||
|
tag = imageTags?.get(ImageType.PRIMARY),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> imageHelper.getResourceUrl(context, R.drawable.tile_land_tv)
|
||||||
|
}.let(ImageProvider::getImageUri)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the resume and next up episodes. The returned pair contains two lists:
|
||||||
|
* 1. resume items
|
||||||
|
* 2. next up items
|
||||||
|
*/
|
||||||
|
private suspend fun getNextUpItems(): Pair<List<BaseItemDto>, List<BaseItemDto>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val resume = async {
|
||||||
|
api.itemsApi.getResumeItems(
|
||||||
|
fields = listOf(ItemFields.DATE_CREATED),
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
limit = 10,
|
||||||
|
mediaTypes = listOf(MediaType.VIDEO),
|
||||||
|
includeItemTypes = listOf(BaseItemKind.EPISODE, BaseItemKind.MOVIE),
|
||||||
|
excludeActiveSessions = true,
|
||||||
|
).content.items
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextUp = async {
|
||||||
|
api.tvShowsApi.getNextUp(
|
||||||
|
imageTypeLimit = 1,
|
||||||
|
limit = 10,
|
||||||
|
enableResumable = false,
|
||||||
|
fields = listOf(ItemFields.DATE_CREATED),
|
||||||
|
).content.items
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concat
|
||||||
|
Pair(resume.await(), nextUp.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestMedia(): Triple<List<BaseItemDto>, List<BaseItemDto>, List<BaseItemDto>> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val latestEpisodes = async {
|
||||||
|
api.userLibraryApi.getLatestMedia(
|
||||||
|
fields = listOf(
|
||||||
|
ItemFields.OVERVIEW,
|
||||||
|
),
|
||||||
|
limit = 50,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.EPISODE),
|
||||||
|
isPlayed = false
|
||||||
|
).content
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestMovies = async {
|
||||||
|
api.userLibraryApi.getLatestMedia(
|
||||||
|
fields = listOf(
|
||||||
|
ItemFields.OVERVIEW,
|
||||||
|
),
|
||||||
|
limit = 50,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE),
|
||||||
|
isPlayed = false
|
||||||
|
).content
|
||||||
|
}
|
||||||
|
|
||||||
|
val latestMedia = async {
|
||||||
|
api.userLibraryApi.getLatestMedia(
|
||||||
|
fields = listOf(
|
||||||
|
ItemFields.OVERVIEW,
|
||||||
|
),
|
||||||
|
limit = 50,
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
|
isPlayed = false
|
||||||
|
).content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concat
|
||||||
|
Triple(latestEpisodes.await(), latestMovies.await(), latestMedia.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("RestrictedApi")
|
||||||
|
private fun createPreviewProgram(
|
||||||
|
channelUri: Uri,
|
||||||
|
item: BaseItemDto,
|
||||||
|
preferParentThumb: Boolean
|
||||||
|
): ContentValues {
|
||||||
|
val imageUri = item.getPosterArtImageUrl(preferParentThumb)
|
||||||
|
val seasonString = item.parentIndexNumber?.toString().orEmpty()
|
||||||
|
|
||||||
|
val episodeString = when {
|
||||||
|
item.indexNumberEnd != null && item.indexNumber != null ->
|
||||||
|
"${item.indexNumber}-${item.indexNumberEnd}"
|
||||||
|
|
||||||
|
else -> item.indexNumber?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
return PreviewProgram.Builder()
|
||||||
|
.setChannelId(ContentUris.parseId(channelUri))
|
||||||
|
.setType(
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES
|
||||||
|
BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE
|
||||||
|
BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE
|
||||||
|
BaseItemKind.AUDIO -> WatchNextPrograms.TYPE_TRACK
|
||||||
|
BaseItemKind.PLAYLIST -> WatchNextPrograms.TYPE_PLAYLIST
|
||||||
|
else -> WatchNextPrograms.TYPE_CHANNEL
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setTitle(item.seriesName ?: item.name)
|
||||||
|
.setEpisodeTitle(if (item.type == BaseItemKind.EPISODE) item.name else null)
|
||||||
|
.setSeasonNumber(seasonString, item.parentIndexNumber ?: 0)
|
||||||
|
.setEpisodeNumber(episodeString, item.indexNumber ?: 0)
|
||||||
|
.setDescription(item.overview?.stripHtml())
|
||||||
|
.setReleaseDate(
|
||||||
|
if (item.premiereDate != null) DateTimeFormatter.ISO_DATE.format(item.premiereDate)
|
||||||
|
else null
|
||||||
|
)
|
||||||
|
.setDurationMillis(
|
||||||
|
if (item.runTimeTicks?.ticks != null) {
|
||||||
|
// If we are resuming, we need to show remaining time, cause GoogleTV
|
||||||
|
// ignores setLastPlaybackPositionMillis
|
||||||
|
val duration = item.runTimeTicks?.ticks ?: Duration.ZERO
|
||||||
|
val playbackPosition = item.userData?.playbackPositionTicks?.ticks
|
||||||
|
?: Duration.ZERO
|
||||||
|
(duration - playbackPosition).inWholeMilliseconds.toInt()
|
||||||
|
} else 0
|
||||||
|
)
|
||||||
|
.setPosterArtUri(imageUri)
|
||||||
|
.setPosterArtAspectRatio(
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.COLLECTION_FOLDER,
|
||||||
|
BaseItemKind.EPISODE -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_16_9
|
||||||
|
|
||||||
|
else -> TvContractCompat.PreviewPrograms.ASPECT_RATIO_MOVIE_POSTER
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||||
|
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||||
|
putExtra(StartupActivity.EXTRA_ITEM_IS_USER_VIEW, item.type == BaseItemKind.COLLECTION_FOLDER)
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
.toContentValues()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the "watch next" row with new and unfinished episodes. Does not include movies, music
|
||||||
|
* or other types of media. Uses the [nextUpItems] parameter to store items returned by a
|
||||||
|
* NextUpQuery().
|
||||||
|
*/
|
||||||
|
private fun updateWatchNext(nextUpItems: List<BaseItemDto>) {
|
||||||
|
// Delete current items
|
||||||
|
context.contentResolver.delete(WatchNextPrograms.CONTENT_URI, null, null)
|
||||||
|
|
||||||
|
// Add new items
|
||||||
|
context.contentResolver.bulkInsert(
|
||||||
|
WatchNextPrograms.CONTENT_URI,
|
||||||
|
nextUpItems.map { item -> getBaseItemAsWatchNextProgram(item).toContentValues() }
|
||||||
|
.toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert [BaseItemDto] to [WatchNextProgram]. Assumes the item type is "episode".
|
||||||
|
*/
|
||||||
|
@Suppress("RestrictedApi")
|
||||||
|
private fun getBaseItemAsWatchNextProgram(item: BaseItemDto) =
|
||||||
|
WatchNextProgram.Builder().apply {
|
||||||
|
val preferParentThumb = userPreferences[UserPreferences.seriesThumbnailsEnabled]
|
||||||
|
|
||||||
|
setInternalProviderId(item.id.toString())
|
||||||
|
|
||||||
|
// Poster size & type
|
||||||
|
if (item.type == BaseItemKind.EPISODE) {
|
||||||
|
setType(WatchNextPrograms.TYPE_TV_EPISODE)
|
||||||
|
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_16_9)
|
||||||
|
} else if (item.type == BaseItemKind.MOVIE) {
|
||||||
|
setType(WatchNextPrograms.TYPE_MOVIE)
|
||||||
|
setPosterArtAspectRatio(WatchNextPrograms.ASPECT_RATIO_MOVIE_POSTER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name
|
||||||
|
if (item.seriesName != null) setTitle("${item.seriesName} - ${item.name}")
|
||||||
|
else setTitle(item.name)
|
||||||
|
|
||||||
|
// Poster
|
||||||
|
setPosterArtUri(item.getPosterArtImageUrl(preferParentThumb))
|
||||||
|
|
||||||
|
// Use date created or fallback to current time if unavailable
|
||||||
|
var engagement = item.dateCreated
|
||||||
|
|
||||||
|
when {
|
||||||
|
// User has started playing the episode
|
||||||
|
(item.userData?.playbackPositionTicks ?: 0) > 0 -> {
|
||||||
|
setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE)
|
||||||
|
setLastPlaybackPositionMillis(item.userData!!.playbackPositionTicks.ticks.inWholeMilliseconds.toInt())
|
||||||
|
// Use last played date to prioritize
|
||||||
|
engagement = item.userData?.lastPlayedDate
|
||||||
|
}
|
||||||
|
// First episode of the season
|
||||||
|
item.indexNumber == 1 -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEW)
|
||||||
|
// Default
|
||||||
|
else -> setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT)
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastEngagementTimeUtcMillis(
|
||||||
|
engagement?.toInstant(ZoneOffset.UTC)?.toEpochMilli()
|
||||||
|
?: Instant.now().toEpochMilli()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Episode runtime has been determined
|
||||||
|
item.runTimeTicks?.let { runTimeTicks ->
|
||||||
|
setDurationMillis(runTimeTicks.ticks.inWholeMilliseconds.toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set intent to open the episode
|
||||||
|
setIntent(Intent(context, StartupActivity::class.java).apply {
|
||||||
|
putExtra(StartupActivity.EXTRA_ITEM_ID, item.id.toString())
|
||||||
|
})
|
||||||
|
}.build()
|
||||||
|
}
|
|
@ -0,0 +1,134 @@
|
||||||
|
package org.jellyfin.androidtv.integration
|
||||||
|
|
||||||
|
import android.app.SearchManager
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.UriMatcher
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.BaseColumns
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.androidtv.integration.provider.ImageProvider
|
||||||
|
import org.jellyfin.androidtv.util.ImageHelper
|
||||||
|
import org.jellyfin.androidtv.util.sdk.isUsable
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemFields
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.inject
|
||||||
|
import timber.log.Timber
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
class MediaContentProvider : ContentProvider(), KoinComponent {
|
||||||
|
companion object {
|
||||||
|
private const val AUTHORITY = "${BuildConfig.APPLICATION_ID}.content"
|
||||||
|
private const val SUGGEST_PATH = "suggestions"
|
||||||
|
private const val SEARCH_SUGGEST = 1
|
||||||
|
private const val TICKS_IN_MILLISECOND = 10000
|
||||||
|
private const val DEFAULT_LIMIT = 10
|
||||||
|
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||||
|
addURI(AUTHORITY, "$SUGGEST_PATH/${SearchManager.SUGGEST_URI_PATH_QUERY}", SEARCH_SUGGEST)
|
||||||
|
addURI(AUTHORITY, "$SUGGEST_PATH/${SearchManager.SUGGEST_URI_PATH_QUERY}/*", SEARCH_SUGGEST)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val api by inject<ApiClient>()
|
||||||
|
private val imageHelper by inject<ImageHelper>()
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = api.isUsable
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor? {
|
||||||
|
Timber.d("Query: %s", uri)
|
||||||
|
|
||||||
|
when (uriMatcher.match(uri)) {
|
||||||
|
SEARCH_SUGGEST -> {
|
||||||
|
val query = selectionArgs?.firstOrNull() ?: uri.lastPathSegment ?: return null
|
||||||
|
Timber.d("Search query: $query")
|
||||||
|
|
||||||
|
val limit = uri.getQueryParameter(SearchManager.SUGGEST_PARAMETER_LIMIT)?.toIntOrNull()
|
||||||
|
?: DEFAULT_LIMIT
|
||||||
|
return getSuggestions(query, limit)
|
||||||
|
}
|
||||||
|
else -> throw IllegalArgumentException("Unknown Uri: $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the resumable items or returns null
|
||||||
|
*/
|
||||||
|
private suspend fun searchItems(query: String, limit: Int): BaseItemDtoQueryResult? = try {
|
||||||
|
val items by api.itemsApi.getItems(
|
||||||
|
searchTerm = query,
|
||||||
|
recursive = true,
|
||||||
|
limit = limit,
|
||||||
|
fields = setOf(ItemFields.TAGLINES)
|
||||||
|
)
|
||||||
|
|
||||||
|
items
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err, "Unable to query API for search results")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSuggestions(query: String, limit: Int) = runBlocking {
|
||||||
|
val searchResult = searchItems(query, limit)
|
||||||
|
if (searchResult != null) Timber.d("Query resulted in %d items", searchResult.totalRecordCount)
|
||||||
|
|
||||||
|
val columns = arrayOf(
|
||||||
|
BaseColumns._ID,
|
||||||
|
SearchManager.SUGGEST_COLUMN_DURATION,
|
||||||
|
SearchManager.SUGGEST_COLUMN_IS_LIVE,
|
||||||
|
SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT,
|
||||||
|
SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR,
|
||||||
|
SearchManager.SUGGEST_COLUMN_QUERY,
|
||||||
|
SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE,
|
||||||
|
SearchManager.SUGGEST_COLUMN_TEXT_1,
|
||||||
|
SearchManager.SUGGEST_COLUMN_TEXT_2,
|
||||||
|
SearchManager.SUGGEST_COLUMN_INTENT_ACTION,
|
||||||
|
SearchManager.SUGGEST_COLUMN_INTENT_DATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
MatrixCursor(columns).also { cursor ->
|
||||||
|
searchResult?.items?.forEach { item ->
|
||||||
|
val imageUri = if (item.imageTags?.contains(ImageType.PRIMARY) == true)
|
||||||
|
ImageProvider.getImageUri(api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY))
|
||||||
|
else
|
||||||
|
imageHelper.getResourceUrl(context!!, R.drawable.tile_land_tv)
|
||||||
|
|
||||||
|
cursor.newRow().apply {
|
||||||
|
add(BaseColumns._ID, item.id)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_DURATION, item.runTimeTicks?.run { div(TICKS_IN_MILLISECOND) })
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_IS_LIVE, if (item.isLive == true) 1 else 0)
|
||||||
|
val lastAccess = item.userData?.lastPlayedDate?.atZone(ZoneId.systemDefault())?.toEpochSecond()
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_LAST_ACCESS_HINT, lastAccess)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_PRODUCTION_YEAR, item.premiereDate?.year)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_QUERY, item.name)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_RESULT_CARD_IMAGE, imageUri)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_TEXT_1, item.name)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_TEXT_2, item.taglines?.firstOrNull())
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_INTENT_ACTION, Intent.ACTION_VIEW)
|
||||||
|
add(SearchManager.SUGGEST_COLUMN_INTENT_DATA, item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getType(p0: Uri): String = SearchManager.SUGGEST_MIME_TYPE
|
||||||
|
override fun insert(p0: Uri, p1: ContentValues?): Uri? = null
|
||||||
|
override fun delete(p0: Uri, p1: String?, p2: Array<out String>?): Int = 0
|
||||||
|
override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array<out String>?): Int = 0
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream
|
||||||
|
|
||||||
|
import android.service.dreams.DreamService
|
||||||
|
import androidx.annotation.CallSuper
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
|
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.ViewModelStore
|
||||||
|
import androidx.lifecycle.ViewModelStoreOwner
|
||||||
|
import androidx.lifecycle.setViewTreeLifecycleOwner
|
||||||
|
import androidx.lifecycle.setViewTreeViewModelStoreOwner
|
||||||
|
import androidx.savedstate.SavedStateRegistry
|
||||||
|
import androidx.savedstate.SavedStateRegistryController
|
||||||
|
import androidx.savedstate.SavedStateRegistryOwner
|
||||||
|
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
|
||||||
|
|
||||||
|
abstract class DreamServiceCompat : DreamService(), SavedStateRegistryOwner, ViewModelStoreOwner {
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
private val lifecycleRegistry = LifecycleRegistry(this)
|
||||||
|
|
||||||
|
@Suppress("LeakingThis")
|
||||||
|
private val savedStateRegistryController = SavedStateRegistryController.create(this).apply {
|
||||||
|
performAttach()
|
||||||
|
}
|
||||||
|
|
||||||
|
override val lifecycle: Lifecycle get() = lifecycleRegistry
|
||||||
|
override val viewModelStore = ViewModelStore()
|
||||||
|
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
|
||||||
|
|
||||||
|
@CallSuper
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
savedStateRegistryController.performRestore(null)
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDreamingStarted() {
|
||||||
|
super.onDreamingStarted()
|
||||||
|
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.STARTED
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDreamingStopped() {
|
||||||
|
super.onDreamingStopped()
|
||||||
|
|
||||||
|
lifecycleRegistry.currentState = Lifecycle.State.CREATED
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setContent(content: @Composable () -> Unit) {
|
||||||
|
val view = ComposeView(this)
|
||||||
|
// Set composition strategy
|
||||||
|
view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
|
||||||
|
|
||||||
|
// Inject dependencies normally added by appcompat activities
|
||||||
|
view.setViewTreeLifecycleOwner(this)
|
||||||
|
view.setViewTreeViewModelStoreOwner(this)
|
||||||
|
view.setViewTreeSavedStateRegistryOwner(this)
|
||||||
|
|
||||||
|
// Set content composable
|
||||||
|
view.setContent(content)
|
||||||
|
|
||||||
|
// Set content view
|
||||||
|
setContentView(view)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.toBitmap
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.playback.core.PlaybackManager
|
||||||
|
import org.jellyfin.playback.core.queue.queue
|
||||||
|
import org.jellyfin.playback.jellyfin.queue.baseItem
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.jellyfin.sdk.model.api.ImageFormat
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import timber.log.Timber
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
@SuppressLint("StaticFieldLeak")
|
||||||
|
class DreamViewModel(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val imageLoader: ImageLoader,
|
||||||
|
private val context: Context,
|
||||||
|
playbackManager: PlaybackManager,
|
||||||
|
private val userPreferences: UserPreferences,
|
||||||
|
) : ViewModel() {
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
private val _mediaContent = playbackManager.queue.entry
|
||||||
|
.map { entry ->
|
||||||
|
entry?.baseItem?.let { baseItem ->
|
||||||
|
DreamContent.NowPlaying(entry, baseItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||||
|
|
||||||
|
private val _libraryContent = flow {
|
||||||
|
// Load first library item after 2 seconds
|
||||||
|
// to force the logo at the start of the screensaver
|
||||||
|
emit(null)
|
||||||
|
delay(2.seconds)
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val next = getRandomLibraryShowcase()
|
||||||
|
if (next != null) {
|
||||||
|
emit(next)
|
||||||
|
delay(30.seconds)
|
||||||
|
} else {
|
||||||
|
delay(3.seconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), null)
|
||||||
|
|
||||||
|
val content = combine(_mediaContent, _libraryContent) { mediaContent, libraryContent ->
|
||||||
|
mediaContent ?: libraryContent ?: DreamContent.Logo
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(),
|
||||||
|
initialValue = _mediaContent.value ?: _libraryContent.value ?: DreamContent.Logo,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun getRandomLibraryShowcase(): DreamContent.LibraryShowcase? {
|
||||||
|
val requireParentalRating = userPreferences[UserPreferences.screensaverAgeRatingRequired]
|
||||||
|
val maxParentalRating = userPreferences[UserPreferences.screensaverAgeRatingMax]
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response by api.itemsApi.getItems(
|
||||||
|
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
|
||||||
|
recursive = true,
|
||||||
|
sortBy = listOf(ItemSortBy.RANDOM),
|
||||||
|
limit = 5,
|
||||||
|
imageTypes = listOf(ImageType.BACKDROP),
|
||||||
|
maxOfficialRating = if (maxParentalRating == -1) null else maxParentalRating.toString(),
|
||||||
|
hasParentalRating = if (requireParentalRating) true else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = response.items.firstOrNull { item ->
|
||||||
|
!item.backdropImageTags.isNullOrEmpty()
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
Timber.i("Loading random library showcase item ${item.id}")
|
||||||
|
|
||||||
|
val backdropTag = item.backdropImageTags!!.randomOrNull()
|
||||||
|
?: item.imageTags?.get(ImageType.BACKDROP)
|
||||||
|
|
||||||
|
val logoTag = item.imageTags?.get(ImageType.LOGO)
|
||||||
|
|
||||||
|
val backdropUrl = api.imageApi.getItemImageUrl(
|
||||||
|
itemId = item.id,
|
||||||
|
imageType = ImageType.BACKDROP,
|
||||||
|
tag = backdropTag,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
)
|
||||||
|
|
||||||
|
val logoUrl = api.imageApi.getItemImageUrl(
|
||||||
|
itemId = item.id,
|
||||||
|
imageType = ImageType.LOGO,
|
||||||
|
tag = logoTag,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
)
|
||||||
|
|
||||||
|
val (logo, backdrop) = withContext(Dispatchers.IO) {
|
||||||
|
val logoDeferred = async {
|
||||||
|
imageLoader.execute(
|
||||||
|
request = ImageRequest.Builder(context).data(logoUrl).build()
|
||||||
|
).image?.toBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
val backdropDeferred = async {
|
||||||
|
imageLoader.execute(
|
||||||
|
request = ImageRequest.Builder(context).data(backdropUrl).build()
|
||||||
|
).image?.toBitmap()
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitAll(logoDeferred, backdropDeferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backdrop == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return DreamContent.LibraryShowcase(item, backdrop, logo)
|
||||||
|
} catch (err: ApiClientException) {
|
||||||
|
Timber.e(err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream
|
||||||
|
|
||||||
|
import android.service.dreams.DreamService
|
||||||
|
import org.jellyfin.androidtv.integration.dream.composable.DreamHost
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An Android [DreamService] (screensaver) that shows TV series and movies from all libraries.
|
||||||
|
* Use `adb shell am start -n "com.android.systemui/.Somnambulator"` to start after changing the
|
||||||
|
* default screensaver in the device settings.
|
||||||
|
*/
|
||||||
|
class LibraryDreamService : DreamServiceCompat() {
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
|
||||||
|
isInteractive = false
|
||||||
|
isFullscreen = true
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
DreamHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,69 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||||
|
import org.jellyfin.androidtv.ui.composable.ZoomBox
|
||||||
|
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamContentLibraryShowcase(
|
||||||
|
content: DreamContent.LibraryShowcase,
|
||||||
|
) = Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
ZoomBox(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = 1.1f,
|
||||||
|
delayMillis = 1_000,
|
||||||
|
durationMillis = 30_000,
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = content.backdrop.asImageBitmap(),
|
||||||
|
contentDescription = null,
|
||||||
|
alignment = Alignment.Center,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Image vignette
|
||||||
|
DreamContentVignette()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.overscan(),
|
||||||
|
) {
|
||||||
|
if (content.logo != null) {
|
||||||
|
Image(
|
||||||
|
bitmap = content.logo.asImageBitmap(),
|
||||||
|
contentDescription = content.item.name,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(75.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = content.item.name.orEmpty(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 32.sp
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamContentLogo() = Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.app_logo),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.width(400.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,170 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import android.widget.ImageView
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||||
|
import org.jellyfin.androidtv.ui.composable.AsyncImage
|
||||||
|
import org.jellyfin.androidtv.ui.composable.LyricsDtoBox
|
||||||
|
import org.jellyfin.androidtv.ui.composable.blurHashPainter
|
||||||
|
import org.jellyfin.androidtv.ui.composable.modifier.fadingEdges
|
||||||
|
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||||
|
import org.jellyfin.androidtv.ui.composable.rememberPlayerProgress
|
||||||
|
import org.jellyfin.playback.core.PlaybackManager
|
||||||
|
import org.jellyfin.playback.core.model.PlayState
|
||||||
|
import org.jellyfin.playback.jellyfin.lyrics
|
||||||
|
import org.jellyfin.playback.jellyfin.lyricsFlow
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||||
|
import org.jellyfin.sdk.model.api.ImageFormat
|
||||||
|
import org.jellyfin.sdk.model.api.ImageType
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamContentNowPlaying(
|
||||||
|
content: DreamContent.NowPlaying,
|
||||||
|
) = Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
val api = koinInject<ApiClient>()
|
||||||
|
val playbackManager = koinInject<PlaybackManager>()
|
||||||
|
val lyrics = content.entry.run { lyricsFlow.collectAsState(lyrics) }.value
|
||||||
|
val progress = rememberPlayerProgress(playbackManager)
|
||||||
|
|
||||||
|
val primaryImageTag = content.item.imageTags?.get(ImageType.PRIMARY)
|
||||||
|
val (imageItemId, imageTag) = when {
|
||||||
|
primaryImageTag != null -> content.item.id to primaryImageTag
|
||||||
|
(content.item.albumId != null && content.item.albumPrimaryImageTag != null) -> content.item.albumId to content.item.albumPrimaryImageTag
|
||||||
|
else -> null to null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background
|
||||||
|
val imageBlurHash = imageTag?.let { tag ->
|
||||||
|
content.item.imageBlurHashes?.get(ImageType.PRIMARY)?.get(tag)
|
||||||
|
}
|
||||||
|
if (imageBlurHash != null) {
|
||||||
|
Image(
|
||||||
|
painter = blurHashPainter(imageBlurHash, IntSize(32, 32)),
|
||||||
|
contentDescription = null,
|
||||||
|
alignment = Alignment.Center,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
DreamContentVignette()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lyrics overlay (on top of background)
|
||||||
|
if (lyrics != null) {
|
||||||
|
val playState by playbackManager.state.playState.collectAsState()
|
||||||
|
LyricsDtoBox(
|
||||||
|
lyricDto = lyrics,
|
||||||
|
currentTimestamp = playbackManager.state.positionInfo.active,
|
||||||
|
duration = playbackManager.state.positionInfo.duration,
|
||||||
|
paused = playState != PlayState.PLAYING,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.fadingEdges(vertical = 250.dp)
|
||||||
|
.padding(horizontal = 50.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata overlay (includes title / progress)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomStart)
|
||||||
|
.overscan(),
|
||||||
|
) {
|
||||||
|
if (imageItemId != null) {
|
||||||
|
AsyncImage(
|
||||||
|
url = api.imageApi.getItemImageUrl(
|
||||||
|
itemId = imageItemId,
|
||||||
|
imageType = ImageType.PRIMARY,
|
||||||
|
tag = imageTag,
|
||||||
|
format = ImageFormat.WEBP,
|
||||||
|
),
|
||||||
|
blurHash = imageBlurHash,
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_CROP,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(128.dp)
|
||||||
|
.clip(RoundedCornerShape(5.dp))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(bottom = 10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = content.item.name.orEmpty(),
|
||||||
|
style = TextStyle(
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 26.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = content.item.run {
|
||||||
|
val artistNames = artists.orEmpty()
|
||||||
|
val albumArtistNames = albumArtists?.mapNotNull { it.name }.orEmpty()
|
||||||
|
|
||||||
|
when {
|
||||||
|
artistNames.isNotEmpty() -> artistNames
|
||||||
|
albumArtistNames.isNotEmpty() -> albumArtistNames
|
||||||
|
else -> listOfNotNull(albumArtist)
|
||||||
|
}.joinToString(", ")
|
||||||
|
},
|
||||||
|
style = TextStyle(
|
||||||
|
color = Color(0.8f, 0.8f, 0.8f),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.drawWithContent {
|
||||||
|
// Background
|
||||||
|
drawRect(Color.White, alpha = 0.2f)
|
||||||
|
// Foreground
|
||||||
|
drawRect(
|
||||||
|
Color.White,
|
||||||
|
size = size.copy(width = progress * size.width)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.geometry.center
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.RadialGradientShader
|
||||||
|
import androidx.compose.ui.graphics.Shader
|
||||||
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
|
|
||||||
|
private val vignetteBrush = object : ShaderBrush() {
|
||||||
|
override fun createShader(size: Size): Shader = RadialGradientShader(
|
||||||
|
colors = listOf(
|
||||||
|
Color.Black.copy(alpha = 0.2f),
|
||||||
|
Color.Black.copy(alpha = 0.7f),
|
||||||
|
),
|
||||||
|
center = size.center,
|
||||||
|
radius = maxOf(size.width, size.height) / 2f,
|
||||||
|
colorStops = listOf(0f, 0.95f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamContentVignette(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) = Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(vignetteBrush)
|
||||||
|
.fillMaxSize()
|
||||||
|
)
|
|
@ -0,0 +1,72 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.androidtv.ui.composable.modifier.overscan
|
||||||
|
import org.jellyfin.androidtv.ui.composable.rememberCurrentTime
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamHeader(
|
||||||
|
showLogo: Boolean,
|
||||||
|
showClock: Boolean,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.overscan(),
|
||||||
|
) {
|
||||||
|
// Logo
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showLogo,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.height(41.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.app_logo),
|
||||||
|
contentDescription = stringResource(R.string.app_name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clock
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showClock,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
) {
|
||||||
|
val time = rememberCurrentTime()
|
||||||
|
Text(
|
||||||
|
text = DateUtils.formatDateTime(LocalContext.current, time, DateUtils.FORMAT_SHOW_TIME),
|
||||||
|
style = TextStyle(
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 20.sp
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import org.jellyfin.androidtv.integration.dream.DreamViewModel
|
||||||
|
import org.jellyfin.androidtv.preference.UserPreferences
|
||||||
|
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||||
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamHost() {
|
||||||
|
val viewModel = koinViewModel<DreamViewModel>()
|
||||||
|
val userPreferences = koinInject<UserPreferences>()
|
||||||
|
val content by viewModel.content.collectAsState()
|
||||||
|
|
||||||
|
DreamView(
|
||||||
|
content = content,
|
||||||
|
showClock = when (userPreferences[UserPreferences.clockBehavior]) {
|
||||||
|
ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.composable
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.snap
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.jellyfin.androidtv.integration.dream.model.DreamContent
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DreamView(
|
||||||
|
content: DreamContent,
|
||||||
|
showClock: Boolean,
|
||||||
|
) = Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = content,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(tween(durationMillis = 1_000)) togetherWith fadeOut(snap(delayMillis = 1_000))
|
||||||
|
},
|
||||||
|
label = "DreamContentTransition"
|
||||||
|
) { content ->
|
||||||
|
when (content) {
|
||||||
|
DreamContent.Logo -> DreamContentLogo()
|
||||||
|
is DreamContent.LibraryShowcase -> DreamContentLibraryShowcase(content)
|
||||||
|
is DreamContent.NowPlaying -> DreamContentNowPlaying(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header overlay
|
||||||
|
DreamHeader(
|
||||||
|
showLogo = content != DreamContent.Logo,
|
||||||
|
showClock = showClock,
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package org.jellyfin.androidtv.integration.dream.model
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import org.jellyfin.playback.core.queue.QueueEntry
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
|
||||||
|
sealed interface DreamContent {
|
||||||
|
data object Logo : DreamContent
|
||||||
|
data class LibraryShowcase(val item: BaseItemDto, val backdrop: Bitmap, val logo: Bitmap?) : DreamContent
|
||||||
|
data class NowPlaying(val entry: QueueEntry, val item: BaseItemDto) : DreamContent
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
package org.jellyfin.androidtv.integration.provider
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.asDrawable
|
||||||
|
import coil3.request.ImageRequest
|
||||||
|
import coil3.request.error
|
||||||
|
import org.jellyfin.androidtv.BuildConfig
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class ImageProvider : ContentProvider() {
|
||||||
|
private val imageLoader by inject<ImageLoader>()
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = true
|
||||||
|
|
||||||
|
override fun getType(uri: Uri) = null
|
||||||
|
override fun query(uri: Uri, projection: Array<out String>?, selection: String?, selectionArgs: Array<out String>?, sortOrder: String?) = null
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?) = null
|
||||||
|
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?) = 0
|
||||||
|
|
||||||
|
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
|
||||||
|
val src = requireNotNull(uri.getQueryParameter("src")).toUri()
|
||||||
|
|
||||||
|
val (read, write) = ParcelFileDescriptor.createPipe()
|
||||||
|
val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(write)
|
||||||
|
|
||||||
|
imageLoader.enqueue(ImageRequest.Builder(context!!).apply {
|
||||||
|
data(src)
|
||||||
|
error(R.drawable.placeholder_icon)
|
||||||
|
target(
|
||||||
|
onSuccess = { image -> writeDrawable(image.asDrawable(context!!.resources), outputStream) },
|
||||||
|
onError = { image -> writeDrawable(requireNotNull(image?.asDrawable(context!!.resources)), outputStream) }
|
||||||
|
)
|
||||||
|
}.build())
|
||||||
|
|
||||||
|
return read
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeDrawable(
|
||||||
|
drawable: Drawable,
|
||||||
|
outputStream: ParcelFileDescriptor.AutoCloseOutputStream
|
||||||
|
) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val format = when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY
|
||||||
|
else -> Bitmap.CompressFormat.WEBP
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
outputStream.use {
|
||||||
|
drawable.toBitmap().compress(format, COMPRESSION_QUALITY, outputStream)
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
// Ignore IOException as this is commonly thrown when the load request is cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val COMPRESSION_QUALITY = 95
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a [Uri] that uses the [ImageProvider] to load an image. The input should be a valid
|
||||||
|
* Jellyfin image URL created using the SDK.
|
||||||
|
*/
|
||||||
|
fun getImageUri(src: String): Uri = Uri.Builder()
|
||||||
|
.scheme("content")
|
||||||
|
.authority("${BuildConfig.APPLICATION_ID}.integration.provider.ImageProvider")
|
||||||
|
.appendQueryParameter("src", src)
|
||||||
|
.appendQueryParameter("v", BuildConfig.VERSION_NAME)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.constant.GridDirection
|
||||||
|
import org.jellyfin.androidtv.constant.ImageType
|
||||||
|
import org.jellyfin.androidtv.constant.PosterSize
|
||||||
|
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.enumPreference
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
import org.jellyfin.sdk.model.api.SortOrder
|
||||||
|
|
||||||
|
class LibraryPreferences(
|
||||||
|
displayPreferencesId: String,
|
||||||
|
api: ApiClient,
|
||||||
|
) : DisplayPreferencesStore(
|
||||||
|
displayPreferencesId = displayPreferencesId,
|
||||||
|
api = api,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val posterSize = enumPreference("PosterSize", PosterSize.MED)
|
||||||
|
val imageType = enumPreference("ImageType", ImageType.POSTER)
|
||||||
|
val gridDirection = enumPreference("GridDirection", GridDirection.HORIZONTAL)
|
||||||
|
val enableSmartScreen = booleanPreference("SmartScreen", false)
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
val filterFavoritesOnly = booleanPreference("FilterFavoritesOnly", false)
|
||||||
|
val filterUnwatchedOnly = booleanPreference("FilterUnwatchedOnly", false)
|
||||||
|
|
||||||
|
// Item sorting
|
||||||
|
val sortBy = enumPreference("SortBy", ItemSortBy.SORT_NAME)
|
||||||
|
val sortOrder = enumPreference("SortOrder", SortOrder.ASCENDING)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.stringPreference
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
||||||
|
class LiveTvPreferences(
|
||||||
|
api: ApiClient,
|
||||||
|
) : DisplayPreferencesStore(
|
||||||
|
displayPreferencesId = "livetv",
|
||||||
|
api = api,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val channelOrder = stringPreference("livetv-channelorder", ItemSortBy.DATE_PLAYED.name)
|
||||||
|
val colorCodeGuide = booleanPreference("guide-colorcodedbackgrounds", false)
|
||||||
|
val favsAtTop = booleanPreference("livetv-favoritechannelsattop", true)
|
||||||
|
val showHDIndicator = booleanPreference("guide-indicator-hd", false)
|
||||||
|
val showLiveIndicator = booleanPreference("guide-indicator-live", true)
|
||||||
|
val showNewIndicator = booleanPreference("guide-indicator-new", false)
|
||||||
|
val showPremiereIndicator = booleanPreference("guide-indicator-premiere", true)
|
||||||
|
val showRepeatIndicator = booleanPreference("guide-indicator-repeat", false)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import kotlin.collections.set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository to access special preference stores.
|
||||||
|
*/
|
||||||
|
class PreferencesRepository(
|
||||||
|
private val api: ApiClient,
|
||||||
|
private val liveTvPreferences: LiveTvPreferences,
|
||||||
|
private val userSettingPreferences: UserSettingPreferences,
|
||||||
|
) {
|
||||||
|
private val libraryPreferences = mutableMapOf<String, LibraryPreferences>()
|
||||||
|
|
||||||
|
fun getLibraryPreferences(preferencesId: String): LibraryPreferences {
|
||||||
|
val store = libraryPreferences[preferencesId] ?: LibraryPreferences(preferencesId, api)
|
||||||
|
|
||||||
|
libraryPreferences[preferencesId] = store
|
||||||
|
|
||||||
|
// FIXME: Make [getLibraryPreferences] suspended when usages are converted to Kotlin
|
||||||
|
if (store.shouldUpdate) runBlocking { store.update() }
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun onSessionChanged() {
|
||||||
|
// Note: Do not run parallel as the server can't deal with that
|
||||||
|
// Relevant server issue: https://github.com/jellyfin/jellyfin/issues/5261
|
||||||
|
liveTvPreferences.update()
|
||||||
|
userSettingPreferences.update()
|
||||||
|
|
||||||
|
libraryPreferences.clear()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||||
|
import org.jellyfin.preference.stringPreference
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System preferences are not possible to modify by the user.
|
||||||
|
* They are mostly used to store states for various filters and warnings.
|
||||||
|
*
|
||||||
|
* @param context Context to get the SharedPreferences from
|
||||||
|
*/
|
||||||
|
class SystemPreferences(context: Context) : SharedPreferenceStore(
|
||||||
|
sharedPreferences = context.getSharedPreferences("systemprefs", Context.MODE_PRIVATE)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Live TV - Channel history
|
||||||
|
/**
|
||||||
|
* Stores the channel that was active before leaving the app
|
||||||
|
*/
|
||||||
|
val liveTvLastChannel = stringPreference("sys_pref_last_tv_channel", "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also stores the channel that was active before leaving the app I think
|
||||||
|
*/
|
||||||
|
val liveTvPrevChannel = stringPreference("sys_pref_prev_tv_channel", "")
|
||||||
|
|
||||||
|
// Live TV - Guide Filters
|
||||||
|
/**
|
||||||
|
* Stores whether the kids filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterKids = booleanPreference("guide_filter_kids", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores whether the movies filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterMovies = booleanPreference("guide_filter_movies", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores whether the news filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterNews = booleanPreference("guide_filter_news", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores whether the premiere filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterPremiere = booleanPreference("guide_filter_premiere", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores whether the series filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterSeries = booleanPreference("guide_filter_series", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores whether the sports filter is active in the channel guide or not
|
||||||
|
*/
|
||||||
|
val liveTvGuideFilterSports = booleanPreference("guide_filter_sports", false)
|
||||||
|
|
||||||
|
// Other persistent variables
|
||||||
|
/**
|
||||||
|
* The version name for the latest dismissed beta notification or empty if none.
|
||||||
|
*/
|
||||||
|
val dismissedBetaNotificationVersion = stringPreference("dismissed_beta_notification_version", "")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to disable the "UI mode" warning that shows when using the app on non TV devices.
|
||||||
|
*/
|
||||||
|
val disableUiModeWarning = booleanPreference("disable_ui_mode_warning", false)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.acra.ACRA
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||||
|
import org.jellyfin.preference.stringPreference
|
||||||
|
|
||||||
|
class TelemetryPreferences(context: Context) : SharedPreferenceStore(
|
||||||
|
sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val SHARED_PREFERENCES_NAME = "telemetry"
|
||||||
|
|
||||||
|
var crashReportEnabled = booleanPreference(ACRA.PREF_ENABLE_ACRA, true)
|
||||||
|
var crashReportIncludeLogs = booleanPreference(ACRA.PREF_ENABLE_SYSTEM_LOGS, true)
|
||||||
|
|
||||||
|
var crashReportToken = stringPreference("server_token", "")
|
||||||
|
var crashReportUrl = stringPreference("server_url", "")
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,258 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.jellyfin.androidtv.preference.constant.AppTheme
|
||||||
|
import org.jellyfin.androidtv.preference.constant.AudioBehavior
|
||||||
|
import org.jellyfin.androidtv.preference.constant.ClockBehavior
|
||||||
|
import org.jellyfin.androidtv.preference.constant.NextUpBehavior
|
||||||
|
import org.jellyfin.androidtv.preference.constant.RatingType
|
||||||
|
import org.jellyfin.androidtv.preference.constant.RefreshRateSwitchingBehavior
|
||||||
|
import org.jellyfin.androidtv.preference.constant.WatchedIndicatorBehavior
|
||||||
|
import org.jellyfin.androidtv.preference.constant.ZoomMode
|
||||||
|
import org.jellyfin.androidtv.ui.playback.segment.MediaSegmentAction
|
||||||
|
import org.jellyfin.androidtv.ui.playback.segment.toMediaSegmentActionsString
|
||||||
|
import org.jellyfin.preference.booleanPreference
|
||||||
|
import org.jellyfin.preference.enumPreference
|
||||||
|
import org.jellyfin.preference.floatPreference
|
||||||
|
import org.jellyfin.preference.intPreference
|
||||||
|
import org.jellyfin.preference.longPreference
|
||||||
|
import org.jellyfin.preference.store.SharedPreferenceStore
|
||||||
|
import org.jellyfin.preference.stringPreference
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User preferences are configurable by the user and change behavior of the application.
|
||||||
|
* When changing preferences migration should be added to the init function.
|
||||||
|
*
|
||||||
|
* @param context Context to get the SharedPreferences from
|
||||||
|
*/
|
||||||
|
class UserPreferences(context: Context) : SharedPreferenceStore(
|
||||||
|
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/* Display */
|
||||||
|
/**
|
||||||
|
* Select the app theme
|
||||||
|
*/
|
||||||
|
var appTheme = enumPreference("app_theme", AppTheme.MUTED_PURPLE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable background images while browsing
|
||||||
|
*/
|
||||||
|
var backdropEnabled = booleanPreference("pref_show_backdrop", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show premieres on home screen
|
||||||
|
*/
|
||||||
|
var premieresEnabled = booleanPreference("pref_enable_premieres", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable management of media like deleting items when the user has sufficient permissions.
|
||||||
|
*/
|
||||||
|
var mediaManagementEnabled = booleanPreference("enable_media_management", false)
|
||||||
|
|
||||||
|
/* Playback - General*/
|
||||||
|
/**
|
||||||
|
* Maximum bitrate in megabit for playback.
|
||||||
|
*/
|
||||||
|
var maxBitrate = stringPreference("pref_max_bitrate", "100")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-play next item
|
||||||
|
*/
|
||||||
|
var mediaQueuingEnabled = booleanPreference("pref_enable_tv_queuing", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the next up screen or not
|
||||||
|
*/
|
||||||
|
var nextUpBehavior = enumPreference("next_up_behavior", NextUpBehavior.EXTENDED)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Next up timeout before playing next item
|
||||||
|
* Stored in milliseconds
|
||||||
|
*/
|
||||||
|
var nextUpTimeout = intPreference("next_up_timeout", 1000 * 7)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duration in seconds to subtract from resume time
|
||||||
|
*/
|
||||||
|
var resumeSubtractDuration = stringPreference("pref_resume_preroll", "0")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable cinema mode
|
||||||
|
*/
|
||||||
|
var cinemaModeEnabled = booleanPreference("pref_enable_cinema_mode", true)
|
||||||
|
|
||||||
|
/* Playback - Video */
|
||||||
|
/**
|
||||||
|
* Whether to use an external playback application or not.
|
||||||
|
*/
|
||||||
|
var useExternalPlayer = booleanPreference("external_player", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change refresh rate to match media when device supports it
|
||||||
|
*/
|
||||||
|
var refreshRateSwitchingBehavior = enumPreference("refresh_rate_switching_behavior", RefreshRateSwitchingBehavior.DISABLED)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether ExoPlayer should prefer FFmpeg renderers to core ones.
|
||||||
|
*/
|
||||||
|
var preferExoPlayerFfmpeg = booleanPreference("exoplayer_prefer_ffmpeg", defaultValue = false)
|
||||||
|
|
||||||
|
/* Playback - Audio related */
|
||||||
|
/**
|
||||||
|
* Preferred behavior for audio streaming.
|
||||||
|
*/
|
||||||
|
var audioBehaviour = enumPreference("audio_behavior", AudioBehavior.DIRECT_STREAM)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferred behavior for audio streaming.
|
||||||
|
*/
|
||||||
|
var audioNightMode = enumPreference("audio_night_mode", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable AC3
|
||||||
|
*/
|
||||||
|
var ac3Enabled = booleanPreference("pref_bitstream_ac3", true)
|
||||||
|
|
||||||
|
/* Live TV */
|
||||||
|
/**
|
||||||
|
* Use direct play
|
||||||
|
*/
|
||||||
|
var liveTvDirectPlayEnabled = booleanPreference("pref_live_direct", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut used for changing the audio track
|
||||||
|
*/
|
||||||
|
var shortcutAudioTrack = intPreference("shortcut_audio_track", KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortcut used for changing the subtitle track
|
||||||
|
*/
|
||||||
|
var shortcutSubtitleTrack = intPreference("shortcut_subtitle_track", KeyEvent.KEYCODE_CAPTIONS)
|
||||||
|
|
||||||
|
/* Developer options */
|
||||||
|
/**
|
||||||
|
* Show additional debug information
|
||||||
|
*/
|
||||||
|
var debuggingEnabled = booleanPreference("pref_enable_debug", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use playback rewrite module for video
|
||||||
|
*/
|
||||||
|
var playbackRewriteVideoEnabled = booleanPreference("playback_new", false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When to show the clock.
|
||||||
|
*/
|
||||||
|
var clockBehavior = enumPreference("pref_clock_behavior", ClockBehavior.ALWAYS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set which ratings provider should show on MyImageCardViews
|
||||||
|
*/
|
||||||
|
var defaultRatingType = enumPreference("pref_rating_type", RatingType.RATING_TOMATOES)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set when watched indicators should show on MyImageCardViews
|
||||||
|
*/
|
||||||
|
var watchedIndicatorBehavior = enumPreference("pref_watched_indicator_behavior", WatchedIndicatorBehavior.ALWAYS)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable series thumbnails in home screen rows
|
||||||
|
*/
|
||||||
|
var seriesThumbnailsEnabled = booleanPreference("pref_enable_series_thumbnails", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles foreground color
|
||||||
|
*/
|
||||||
|
var subtitlesBackgroundColor = longPreference("subtitles_background_color", 0x00FFFFFF)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles foreground color
|
||||||
|
*/
|
||||||
|
var subtitlesTextColor = longPreference("subtitles_text_color", 0xFFFFFFFF)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles stroke color
|
||||||
|
*/
|
||||||
|
var subtitleTextStrokeColor = longPreference("subtitles_text_stroke_color", 0xFF000000)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subtitles font size
|
||||||
|
*/
|
||||||
|
var subtitlesTextSize = floatPreference("subtitles_text_size", 1f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show screensaver in app
|
||||||
|
*/
|
||||||
|
var screensaverInAppEnabled = booleanPreference("screensaver_inapp_enabled", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeout before showing the screensaver in app, depends on [screensaverInAppEnabled].
|
||||||
|
*/
|
||||||
|
var screensaverInAppTimeout = longPreference("screensaver_inapp_timeout", 5.minutes.inWholeMilliseconds)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Age rating used to filter items in the screensaver. Use -1 to disable (omits parameter from requests).
|
||||||
|
*/
|
||||||
|
var screensaverAgeRatingMax = intPreference("screensaver_agerating_max", 13)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether items shown in the screensaver are required to have an age rating set.
|
||||||
|
*/
|
||||||
|
var screensaverAgeRatingRequired = booleanPreference("screensaver_agerating_required", true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delay when starting video playback after loading the video player.
|
||||||
|
*/
|
||||||
|
var videoStartDelay = longPreference("video_start_delay", 0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actions to take for each media segment type. Managed by the [MediaSegmentRepository].
|
||||||
|
*/
|
||||||
|
var mediaSegmentActions = stringPreference(
|
||||||
|
key = "media_segment_actions",
|
||||||
|
defaultValue = mapOf(
|
||||||
|
MediaSegmentType.INTRO to MediaSegmentAction.ASK_TO_SKIP,
|
||||||
|
MediaSegmentType.OUTRO to MediaSegmentAction.ASK_TO_SKIP,
|
||||||
|
).toMediaSegmentActionsString()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferred behavior for player aspect ratio (zoom mode).
|
||||||
|
*/
|
||||||
|
var playerZoomMode = enumPreference("player_zoom_mode", ZoomMode.FIT)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable TrickPlay in legacy player user interface while seeking.
|
||||||
|
*/
|
||||||
|
var trickPlayEnabled = booleanPreference("trick_play_enabled", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Note: Create a single migration per app version
|
||||||
|
// Note: Migrations are never executed for fresh installs
|
||||||
|
// Note: Old migrations are removed occasionally
|
||||||
|
runMigrations {
|
||||||
|
// v0.15.z to v0.16.0
|
||||||
|
migration(toVersion = 7) {
|
||||||
|
// Enable playback rewrite for music
|
||||||
|
putBoolean("playback_new_audio", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v0.17.z to v0.18.0
|
||||||
|
migration(toVersion = 8) {
|
||||||
|
// Set subtitle background color to black if it was enabled in a previous version
|
||||||
|
val subtitlesBackgroundEnabled = it.getBoolean("subtitles_background_enabled", true)
|
||||||
|
putLong("subtitles_background_color", if (subtitlesBackgroundEnabled) 0XFF000000L else 0X00FFFFFFL)
|
||||||
|
|
||||||
|
// Set subtitle text stroke color to black if it was enabled in a previous version
|
||||||
|
val subtitleStrokeSize = it.getInt("subtitles_stroke_size", 0)
|
||||||
|
putLong("subtitles_text_stroke_color", if (subtitleStrokeSize > 0) 0XFF000000L else 0X00FFFFFFL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package org.jellyfin.androidtv.preference
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.constant.HomeSectionType
|
||||||
|
import org.jellyfin.androidtv.preference.store.DisplayPreferencesStore
|
||||||
|
import org.jellyfin.preference.enumPreference
|
||||||
|
import org.jellyfin.preference.intPreference
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
|
||||||
|
class UserSettingPreferences(
|
||||||
|
api: ApiClient,
|
||||||
|
) : DisplayPreferencesStore(
|
||||||
|
displayPreferencesId = "usersettings",
|
||||||
|
api = api,
|
||||||
|
app = "emby",
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val skipBackLength = intPreference("skipBackLength", 10_000)
|
||||||
|
val skipForwardLength = intPreference("skipForwardLength", 30_000)
|
||||||
|
|
||||||
|
val homesection0 = enumPreference("homesection0", HomeSectionType.LIBRARY_TILES_SMALL)
|
||||||
|
val homesection1 = enumPreference("homesection1", HomeSectionType.RESUME)
|
||||||
|
val homesection2 = enumPreference("homesection2", HomeSectionType.RESUME_AUDIO)
|
||||||
|
val homesection3 = enumPreference("homesection3", HomeSectionType.RESUME_BOOK)
|
||||||
|
val homesection4 = enumPreference("homesection4", HomeSectionType.LIVE_TV)
|
||||||
|
val homesection5 = enumPreference("homesection5", HomeSectionType.NEXT_UP)
|
||||||
|
val homesection6 = enumPreference("homesection6", HomeSectionType.LATEST_MEDIA)
|
||||||
|
val homesection7 = enumPreference("homesection7", HomeSectionType.NONE)
|
||||||
|
val homesection8 = enumPreference("homesection8", HomeSectionType.NONE)
|
||||||
|
val homesection9 = enumPreference("homesection9", HomeSectionType.NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val homesections = listOf(
|
||||||
|
homesection0,
|
||||||
|
homesection1,
|
||||||
|
homesection2,
|
||||||
|
homesection3,
|
||||||
|
homesection4,
|
||||||
|
homesection5,
|
||||||
|
homesection6,
|
||||||
|
homesection7,
|
||||||
|
homesection8,
|
||||||
|
homesection9,
|
||||||
|
)
|
||||||
|
|
||||||
|
val activeHomesections
|
||||||
|
get() = homesections
|
||||||
|
.map(::get)
|
||||||
|
.filterNot { it == HomeSectionType.NONE }
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class AppTheme(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* The default dark theme
|
||||||
|
*/
|
||||||
|
DARK(R.string.pref_theme_dark),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The "classic" emerald theme
|
||||||
|
*/
|
||||||
|
EMERALD(R.string.pref_theme_emerald),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A theme with a more muted accent color, inspired by CTalvio's Monochromic CSS theme for Jellyfin Web
|
||||||
|
*/
|
||||||
|
MUTED_PURPLE(R.string.pref_theme_muted_purple),
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class AudioBehavior(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Directly stream audio without any changes
|
||||||
|
*/
|
||||||
|
DIRECT_STREAM(R.string.pref_audio_direct),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downnmix audio to stereo. Disables the AC3, EAC3 and AAC_LATM audio codecs.
|
||||||
|
*/
|
||||||
|
DOWNMIX_TO_STEREO(R.string.pref_audio_compat),
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class ClockBehavior(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Always show clock.
|
||||||
|
*/
|
||||||
|
ALWAYS(R.string.lbl_always),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show clock in menus only.
|
||||||
|
*/
|
||||||
|
IN_MENUS(R.string.pref_clock_display_browsing),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show clock in video only.
|
||||||
|
*/
|
||||||
|
IN_VIDEO(R.string.pref_clock_display_playback),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show clock never.
|
||||||
|
*/
|
||||||
|
NEVER(R.string.lbl_never),
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||||
|
|
||||||
|
enum class LiveTvChannelOrder(
|
||||||
|
override val nameRes: Int,
|
||||||
|
val stringValue: String,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
LAST_PLAYED(R.string.lbl_guide_option_played, ItemSortBy.DATE_PLAYED.serialName),
|
||||||
|
CHANNEL_NUMBER(R.string.lbl_guide_option_number, ItemSortBy.SORT_NAME.serialName);
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String) = entries
|
||||||
|
.firstOrNull { it.stringValue.equals(value, ignoreCase = true) }
|
||||||
|
?: LAST_PLAYED
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class NextUpBehavior(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Enable the Next Up screen - show the item's thumbnail
|
||||||
|
*/
|
||||||
|
EXTENDED(R.string.lbl_next_up_extended),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the Next Up screen - hide the item's thumbnail
|
||||||
|
*/
|
||||||
|
MINIMAL(R.string.lbl_next_up_minimal),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the Next Up screen
|
||||||
|
*/
|
||||||
|
DISABLED(R.string.lbl_never),
|
||||||
|
}
|
||||||
|
|
||||||
|
const val NEXTUP_TIMER_DISABLED: Int = 0
|
|
@ -0,0 +1,23 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class RatingType(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Sets default rating type to tomatoes.
|
||||||
|
*/
|
||||||
|
RATING_TOMATOES(R.string.lbl_tomatoes),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default rating type to stars.
|
||||||
|
*/
|
||||||
|
RATING_STARS(R.string.lbl_stars),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default rating type to hidden.
|
||||||
|
*/
|
||||||
|
RATING_HIDDEN(R.string.lbl_hidden),
|
||||||
|
}
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class RefreshRateSwitchingBehavior(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
DISABLED(R.string.state_disabled),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When comparing modes, use difference in resolution to rank modes.
|
||||||
|
*/
|
||||||
|
SCALE_ON_TV(R.string.pref_refresh_rate_scale_on_tv),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When comparing modes, rank native resolution modes highest.
|
||||||
|
* Otherwise use difference in resolution to rank modes.
|
||||||
|
*/
|
||||||
|
SCALE_ON_DEVICE(R.string.pref_refresh_rate_scale_on_device),
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
enum class UserSelectBehavior {
|
||||||
|
DISABLED,
|
||||||
|
LAST_USER,
|
||||||
|
SPECIFIC_USER
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
package org.jellyfin.androidtv.preference.constant
|
||||||
|
|
||||||
|
import org.jellyfin.androidtv.R
|
||||||
|
import org.jellyfin.preference.PreferenceEnum
|
||||||
|
|
||||||
|
enum class WatchedIndicatorBehavior(
|
||||||
|
override val nameRes: Int,
|
||||||
|
) : PreferenceEnum {
|
||||||
|
/**
|
||||||
|
* Always show watched indicators.
|
||||||
|
*/
|
||||||
|
ALWAYS(R.string.lbl_always),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide unwatched count indicator, show watched check mark only.
|
||||||
|
*/
|
||||||
|
HIDE_UNWATCHED(R.string.lbl_hide_unwatched_count),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide unwatched count indicator, show watched check mark on individual episodes only.
|
||||||
|
*/
|
||||||
|
EPISODES_ONLY(R.string.lbl_hide_watched_checkmark),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never show watched indicators.
|
||||||
|
*/
|
||||||
|
NEVER(R.string.lbl_never),
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue