This commit is contained in:
AlaskarTV-Bot 2025-01-06 15:34:36 +00:00
commit 0895bc6bf6
971 changed files with 81599 additions and 0 deletions

18
.editorconfig Normal file
View 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
View 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
View file

@ -0,0 +1,9 @@
*.iml
.DS_Store
local.properties
/.idea
.gradle
.kotlin/
build/
captures/

339
LICENSE Normal file
View 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
View 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
View 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)
}

View 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>

View 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>

View file

@ -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)
}
}

View 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<*>>>()
}

View file

@ -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)
}

View file

@ -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"
}
}

View file

@ -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
}
}

View file

@ -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()

View file

@ -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);
}

View file

@ -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(),
)

View file

@ -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,
)

View file

@ -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()

View file

@ -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()

View file

@ -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
}
}

View file

@ -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()

View 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)
}

View file

@ -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
)
}
}

View file

@ -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,
)
}
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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", "")
}
}
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,9 @@
package org.jellyfin.androidtv.constant
enum class ChangeTriggerType {
LibraryUpdated,
MoviePlayback,
TvPlayback,
MusicPlayback,
FavoriteUpdate,
}

View 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"
}
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.androidtv.constant
sealed interface CustomMessage {
data object RefreshCurrentItem : CustomMessage
data object ActionComplete : CustomMessage
}

View file

@ -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"
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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
}

View file

@ -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),
}

View file

@ -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
}

View file

@ -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,
}

View file

@ -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
}

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.compat
import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode
class PlaybackException : RuntimeException() {
var errorCode = PlaybackErrorCode.NotAllowed
}

View file

@ -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));
}
}

View file

@ -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;
}
}

View file

@ -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
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.androidtv.data.compat
class VideoOptions : AudioOptions() {
var audioStreamIndex: Int? = null
var subtitleStreamIndex: Int? = null
}

View file

@ -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
}
}

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.model
data class AppNotification(
val message: String,
val dismiss: () -> Unit,
val public: Boolean,
)

View file

@ -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?,
)

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -0,0 +1,6 @@
package org.jellyfin.androidtv.data.model
data class InfoItem @JvmOverloads constructor(
val label: String = "",
val value: String = "",
)

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.querying;
import java.util.UUID
data class GetAdditionalPartsRequest(
val itemId: UUID,
)

View file

@ -0,0 +1,3 @@
package org.jellyfin.androidtv.data.querying
data object GetSeriesTimersRequest

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetSpecialsRequest(
val itemId: UUID,
)

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.data.querying
import java.util.UUID
data class GetTrailersRequest(
val itemId: UUID,
)

View file

@ -0,0 +1,3 @@
package org.jellyfin.androidtv.data.querying
data object GetUserViewsRequest

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
),
)
}
}
}

View file

@ -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
)
}
}

View file

@ -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()
}
}
}

View 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()) }
}

View 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()) }
}

View 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()) }
}

View file

@ -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)
}

View file

@ -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 }
}

View file

@ -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()) }
}

View file

@ -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()) }
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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()
}
}
}

View file

@ -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
),
)
}
}
}

View file

@ -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()
)
}

View file

@ -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)
)
}
)
}
}
}

View file

@ -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()
)

View file

@ -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
),
)
}
}
}

View file

@ -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
}
)
}

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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", "")
}
}

View file

@ -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)
}
}
}
}

View file

@ -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 }
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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),
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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),
}

View file

@ -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),
}

View file

@ -0,0 +1,7 @@
package org.jellyfin.androidtv.preference.constant
enum class UserSelectBehavior {
DISABLED,
LAST_USER,
SPECIFIC_USER
}

View file

@ -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