commit 0895bc6bf649b03273ca0b5cc24f965cf3ac8bd6 Author: AlaskarTV-Bot Date: Mon Jan 6 15:34:36 2025 +0000 Update diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..549a4f5 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ffbe96e --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c25514a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.DS_Store +local.properties + +/.idea +.gradle +.kotlin/ +build/ +captures/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -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. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 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. + + , 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. diff --git a/android-lint.xml b/android-lint.xml new file mode 100644 index 0000000..6213140 --- /dev/null +++ b/android-lint.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f7fd318 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/src/debug/res/values/logo.xml b/app/src/debug/res/values/logo.xml new file mode 100644 index 0000000..60f1bea --- /dev/null +++ b/app/src/debug/res/values/logo.xml @@ -0,0 +1,7 @@ + + + #F2364D + #FDC92F + #0A0A0A + #FFFFFF + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d61de29 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt new file mode 100644 index 0000000..5985896 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt @@ -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.addDefaultNotifications() + } + + /** + * Called from the StartupActivity when the user session is started. + */ + suspend fun onSessionStart() = withContext(Dispatchers.IO) { + val workManager by inject() + val socketListener by inject() + + // Update background worker + launch { + // Cancel all current workers + workManager.cancelAllWork().await() + + // Recreate periodic workers + workManager.enqueueUniquePeriodicWork( + LeanbackChannelWorker.PERIODIC_UPDATE_REQUEST_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/LogInitializer.kt b/app/src/main/java/org/jellyfin/androidtv/LogInitializer.kt new file mode 100644 index 0000000..3d26605 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/LogInitializer.kt @@ -0,0 +1,28 @@ +package org.jellyfin.androidtv + +import android.content.Context +import androidx.startup.Initializer +import timber.log.Timber + +class LogInitializer : Initializer { + 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>>() +} diff --git a/app/src/main/java/org/jellyfin/androidtv/SessionInitializer.kt b/app/src/main/java/org/jellyfin/androidtv/SessionInitializer.kt new file mode 100644 index 0000000..a8d0440 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/SessionInitializer.kt @@ -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 { + override fun create(context: Context) { + val koin = AppInitializer.getInstance(context) + .initializeComponent(KoinInitializer::class.java) + .koin + + // Restore system session + ProcessLifecycleOwner.get().lifecycleScope.launch { + koin.get().restoreSession(destroyOnly = false) + } + } + + override fun dependencies() = listOf(KoinInitializer::class.java) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/AccountManagerMigration.kt b/app/src/main/java/org/jellyfin/androidtv/auth/AccountManagerMigration.kt new file mode 100644 index 0000000..da20557 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/AccountManagerMigration.kt @@ -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()) + + fun migrate( + servers: Map, + ) = 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" + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/apiclient/ApiBinder.kt b/app/src/main/java/org/jellyfin/androidtv/auth/apiclient/ApiBinder.kt new file mode 100644 index 0000000..efb3381 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/apiclient/ApiBinder.kt @@ -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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticateMethod.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticateMethod.kt new file mode 100644 index 0000000..7906162 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticateMethod.kt @@ -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() diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationSortBy.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationSortBy.kt new file mode 100644 index 0000000..4d1bd10 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationSortBy.kt @@ -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); +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreServer.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreServer.kt new file mode 100644 index 0000000..49477cd --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreServer.kt @@ -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 = emptyMap(), +) diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreUser.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreUser.kt new file mode 100644 index 0000000..02df19a --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/AuthenticationStoreUser.kt @@ -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, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/LoginState.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/LoginState.kt new file mode 100644 index 0000000..7130ade --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/LoginState.kt @@ -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() diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/QuickConnectState.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/QuickConnectState.kt new file mode 100644 index 0000000..78b1f6f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/QuickConnectState.kt @@ -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() diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/Server.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/Server.kt new file mode 100644 index 0000000..adebd7a --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/Server.kt @@ -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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/ServerAdditionState.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/ServerAdditionState.kt new file mode 100644 index 0000000..2e1e222 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/ServerAdditionState.kt @@ -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>) : ServerAdditionState() +data class ConnectedState(val id: UUID, val publicInfo: PublicSystemInfo) : ServerAdditionState() diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/model/User.kt b/app/src/main/java/org/jellyfin/androidtv/auth/model/User.kt new file mode 100644 index 0000000..97ca3cd --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/model/User.kt @@ -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) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/repository/AuthenticationRepository.kt b/app/src/main/java/org/jellyfin/androidtv/auth/repository/AuthenticationRepository.kt new file mode 100644 index 0000000..62775af --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/repository/AuthenticationRepository.kt @@ -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 + 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 { + // 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 { + 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 + ) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerRepository.kt b/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerRepository.kt new file mode 100644 index 0000000..8152476 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerRepository.kt @@ -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> + val discoveredServers: StateFlow> + + suspend fun loadStoredServers() + suspend fun loadDiscoveryServers() + + fun addServer(address: String): Flow + 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()) + override val storedServers = _storedServers.asStateFlow() + + private val _discoveredServers = MutableStateFlow(emptyList()) + override val discoveredServers = _discoveredServers.asStateFlow() + + // Loading data + override suspend fun loadStoredServers() { + authenticationStore.getServers() + .map { (id, entry) -> entry.asServer(id) } + .sortedWith(compareByDescending { it.dateLastAccessed }.thenBy { it.name }) + .let { _storedServers.emit(it) } + } + + override suspend fun loadDiscoveryServers() { + val servers = mutableListOf() + + jellyfin.discovery + .discoverLocalServers() + .map(ServerDiscoveryInfo::toServer) + .collect { server -> + servers += server + _discoveredServers.emit(servers.toList()) + } + } + + // Mutating data + override fun addServer(address: String): Flow = 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() + val badRecommendations = mutableListOf() + 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, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerUserRepository.kt b/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerUserRepository.kt new file mode 100644 index 0000000..73daf7d --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/repository/ServerUserRepository.kt @@ -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 + suspend fun getPublicServerUsers(server: Server): List + + 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 { it.lastUsed }.thenBy { it.name }) + .orEmpty() + + override suspend fun getPublicServerUsers(server: Server): List { + // 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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/repository/SessionRepository.kt b/app/src/main/java/org/jellyfin/androidtv/auth/repository/SessionRepository.kt new file mode 100644 index 0000000..5adf5fb --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/repository/SessionRepository.kt @@ -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 + val state: StateFlow + + 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(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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/repository/UserRepository.kt b/app/src/main/java/org/jellyfin/androidtv/auth/repository/UserRepository.kt new file mode 100644 index 0000000..ed37b97 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/repository/UserRepository.kt @@ -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 + + fun updateCurrentUser(user: UserDto?) +} + +class UserRepositoryImpl : UserRepository { + override val currentUser = MutableStateFlow(null) + + override fun updateCurrentUser(user: UserDto?) { + currentUser.value = user + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/service/AuthenticatorService.kt b/app/src/main/java/org/jellyfin/androidtv/auth/service/AuthenticatorService.kt new file mode 100644 index 0000000..1b3a879 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/service/AuthenticatorService.kt @@ -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?, + 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, + ): Bundle = unsupportedOperationBundle + + override fun updateCredentials( + response: AccountAuthenticatorResponse, + account: Account, + authTokenType: String?, + options: Bundle?, + ): Bundle = unsupportedOperationBundle + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationPreferences.kt new file mode 100644 index 0000000..54fdb92 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationPreferences.kt @@ -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", "") + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationStore.kt b/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationStore.kt new file mode 100644 index 0000000..b658096 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/auth/store/AuthenticationStore.kt @@ -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 { + // 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>(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>(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): 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 = store + + fun getUsers(server: UUID): Map? = 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() + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/ChangeTriggerType.kt b/app/src/main/java/org/jellyfin/androidtv/constant/ChangeTriggerType.kt new file mode 100644 index 0000000..9913135 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/ChangeTriggerType.kt @@ -0,0 +1,9 @@ +package org.jellyfin.androidtv.constant + +enum class ChangeTriggerType { + LibraryUpdated, + MoviePlayback, + TvPlayback, + MusicPlayback, + FavoriteUpdate, +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt b/app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt new file mode 100644 index 0000000..5ec4361 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/Codec.kt @@ -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" + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/CustomMessage.kt b/app/src/main/java/org/jellyfin/androidtv/constant/CustomMessage.kt new file mode 100644 index 0000000..5d8b67c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/CustomMessage.kt @@ -0,0 +1,6 @@ +package org.jellyfin.androidtv.constant + +sealed interface CustomMessage { + data object RefreshCurrentItem : CustomMessage + data object ActionComplete : CustomMessage +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/Extras.kt b/app/src/main/java/org/jellyfin/androidtv/constant/Extras.kt new file mode 100644 index 0000000..43c3f0a --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/Extras.kt @@ -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" +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/GridDirection.kt b/app/src/main/java/org/jellyfin/androidtv/constant/GridDirection.kt new file mode 100644 index 0000000..03b3f3e --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/GridDirection.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/HomeSectionType.kt b/app/src/main/java/org/jellyfin/androidtv/constant/HomeSectionType.kt new file mode 100644 index 0000000..13b3d55 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/HomeSectionType.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/ImageType.kt b/app/src/main/java/org/jellyfin/androidtv/constant/ImageType.kt new file mode 100644 index 0000000..601ba8e --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/ImageType.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/LiveTvOption.kt b/app/src/main/java/org/jellyfin/androidtv/constant/LiveTvOption.kt new file mode 100644 index 0000000..a3a3af9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/LiveTvOption.kt @@ -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 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/PosterSize.kt b/app/src/main/java/org/jellyfin/androidtv/constant/PosterSize.kt new file mode 100644 index 0000000..6b4b8af --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/PosterSize.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/QualityProfiles.kt b/app/src/main/java/org/jellyfin/androidtv/constant/QualityProfiles.kt new file mode 100644 index 0000000..625e01f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/QualityProfiles.kt @@ -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 = 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 +} + diff --git a/app/src/main/java/org/jellyfin/androidtv/constant/QueryType.kt b/app/src/main/java/org/jellyfin/androidtv/constant/QueryType.kt new file mode 100644 index 0000000..f7e3eb0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/constant/QueryType.kt @@ -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, +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/AudioOptions.kt b/app/src/main/java/org/jellyfin/androidtv/data/compat/AudioOptions.kt new file mode 100644 index 0000000..76700db --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/AudioOptions.kt @@ -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? = 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 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/PlaybackException.kt b/app/src/main/java/org/jellyfin/androidtv/data/compat/PlaybackException.kt new file mode 100644 index 0000000..c871110 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/PlaybackException.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.data.compat + +import org.jellyfin.apiclient.model.dlna.PlaybackErrorCode + +class PlaybackException : RuntimeException() { + var errorCode = PlaybackErrorCode.NotAllowed +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamBuilder.java b/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamBuilder.java new file mode 100644 index 0000000..ede54f1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamBuilder.java @@ -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)); + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamInfo.java b/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamInfo.java new file mode 100644 index 0000000..98af59b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/StreamInfo.java @@ -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 getSubtitleProfiles(ApiClient api) { + ArrayList list = new ArrayList(); + + 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 getSelectableAudioStreams() { + return getSelectableStreams(MediaStreamType.AUDIO); + } + + public final ArrayList getSelectableStreams(MediaStreamType type) { + ArrayList list = new ArrayList(); + + for (org.jellyfin.sdk.model.api.MediaStream stream : getMediaSource().getMediaStreams()) { + if (type == stream.getType()) { + list.add(stream); + } + } + + return list; + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/SubtitleStreamInfo.kt b/app/src/main/java/org/jellyfin/androidtv/data/compat/SubtitleStreamInfo.kt new file mode 100644 index 0000000..4ef8d2b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/SubtitleStreamInfo.kt @@ -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 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/compat/VideoOptions.kt b/app/src/main/java/org/jellyfin/androidtv/data/compat/VideoOptions.kt new file mode 100644 index 0000000..3e3d9ca --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/compat/VideoOptions.kt @@ -0,0 +1,6 @@ +package org.jellyfin.androidtv.data.compat + +class VideoOptions : AudioOptions() { + var audioStreamIndex: Int? = null + var subtitleStreamIndex: Int? = null +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt b/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt new file mode 100644 index 0000000..e356e7b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/eventhandling/SocketHandler.kt @@ -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() + .onEach { message -> message.data?.let(::onLibraryChanged) } + .launchIn(coroutineScope) + + // Media playback + subscribe() + .onEach { message -> onPlayMessage(message) } + .launchIn(coroutineScope) + + subscribe() + .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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/model/AppNotification.kt b/app/src/main/java/org/jellyfin/androidtv/data/model/AppNotification.kt new file mode 100644 index 0000000..831c11a --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/model/AppNotification.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.data.model + +data class AppNotification( + val message: String, + val dismiss: () -> Unit, + val public: Boolean, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/model/ChapterItemInfo.kt b/app/src/main/java/org/jellyfin/androidtv/data/model/ChapterItemInfo.kt new file mode 100644 index 0000000..84ad1f6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/model/ChapterItemInfo.kt @@ -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?, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/model/DataRefreshService.kt b/app/src/main/java/org/jellyfin/androidtv/data/model/DataRefreshService.kt new file mode 100644 index 0000000..1da5a0f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/model/DataRefreshService.kt @@ -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 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/model/FilterOptions.kt b/app/src/main/java/org/jellyfin/androidtv/data/model/FilterOptions.kt new file mode 100644 index 0000000..ed95322 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/model/FilterOptions.kt @@ -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 + get() = buildSet { + if (isFavoriteOnly) add(ItemFilter.IS_FAVORITE) + if (isUnwatchedOnly) add(ItemFilter.IS_UNPLAYED) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/model/InfoItem.kt b/app/src/main/java/org/jellyfin/androidtv/data/model/InfoItem.kt new file mode 100644 index 0000000..0fe5912 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/model/InfoItem.kt @@ -0,0 +1,6 @@ +package org.jellyfin.androidtv.data.model + +data class InfoItem @JvmOverloads constructor( + val label: String = "", + val value: String = "", +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/querying/GetAdditionalPartsRequest.kt b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetAdditionalPartsRequest.kt new file mode 100644 index 0000000..dfd0b7d --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetAdditionalPartsRequest.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.data.querying; + +import java.util.UUID + +data class GetAdditionalPartsRequest( + val itemId: UUID, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSeriesTimersRequest.kt b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSeriesTimersRequest.kt new file mode 100644 index 0000000..d33f83b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSeriesTimersRequest.kt @@ -0,0 +1,3 @@ +package org.jellyfin.androidtv.data.querying + +data object GetSeriesTimersRequest diff --git a/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSpecialsRequest.kt b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSpecialsRequest.kt new file mode 100644 index 0000000..a7842e4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetSpecialsRequest.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.data.querying + +import java.util.UUID + +data class GetSpecialsRequest( + val itemId: UUID, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/querying/GetTrailersRequest.kt b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetTrailersRequest.kt new file mode 100644 index 0000000..239f616 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetTrailersRequest.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.data.querying + +import java.util.UUID + +data class GetTrailersRequest( + val itemId: UUID, +) diff --git a/app/src/main/java/org/jellyfin/androidtv/data/querying/GetUserViewsRequest.kt b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetUserViewsRequest.kt new file mode 100644 index 0000000..80014af --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/querying/GetUserViewsRequest.kt @@ -0,0 +1,3 @@ +package org.jellyfin.androidtv.data.querying + +data object GetUserViewsRequest diff --git a/app/src/main/java/org/jellyfin/androidtv/data/repository/CustomMessageRepository.kt b/app/src/main/java/org/jellyfin/androidtv/data/repository/CustomMessageRepository.kt new file mode 100644 index 0000000..5ab70f9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/repository/CustomMessageRepository.kt @@ -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 + fun pushMessage(message: CustomMessage) +} + +class CustomMessageRepositoryImpl : CustomMessageRepository { + private val _message = MutableStateFlow(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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/repository/ItemMutationRepository.kt b/app/src/main/java/org/jellyfin/androidtv/data/repository/ItemMutationRepository.kt new file mode 100644 index 0000000..132d6e1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/repository/ItemMutationRepository.kt @@ -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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/repository/NotificationsRepository.kt b/app/src/main/java/org/jellyfin/androidtv/data/repository/NotificationsRepository.kt new file mode 100644 index 0000000..452f09c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/repository/NotificationsRepository.kt @@ -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> + + 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()) + + 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 + ), + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/repository/UserViewsRepository.kt b/app/src/main/java/org/jellyfin/androidtv/data/repository/UserViewsRepository.kt new file mode 100644 index 0000000..b95558f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/repository/UserViewsRepository.kt @@ -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> + + 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 + ) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt b/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt new file mode 100644 index 0000000..b87687e --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt @@ -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() + private var _currentIndex = 0 + private var _currentBackground = MutableStateFlow(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?.getUrls(itemId: UUID?): List { + // 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) { + 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() + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AndroidModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AndroidModule.kt new file mode 100644 index 0000000..fca95d8 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/AndroidModule.kt @@ -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()!! } + factory { androidApplication().getSystemService()!! } + factory { WorkManager.getInstance(get()) } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt new file mode 100644 index 0000000..4405f97 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt @@ -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().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().createApi( + device = get(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 { UserRepositoryImpl() } + single { UserViewsRepositoryImpl(get()) } + single { NotificationsRepositoryImpl(get(), get()) } + single { ItemMutationRepositoryImpl(get(), get()) } + single { CustomMessageRepositoryImpl() } + single { NavigationRepositoryImpl(Destinations.home) } + single { SearchRepositoryImpl(get()) } + single { 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 { SdkPlaybackHelper(get(), get(), get(), get(), get(), get(), get()) } + + factory { (context: Context) -> SearchFragmentDelegate(context, get(), get()) } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AuthModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AuthModule.kt new file mode 100644 index 0000000..91a38c6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/AuthModule.kt @@ -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 { + AuthenticationRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo)) + } + single { ServerRepositoryImpl(get(), get()) } + single { ServerUserRepositoryImpl(get(), get()) } + single { + SessionRepositoryImpl(get(), get(), get(), get(), get(), get(defaultDeviceInfo), get(), get(), get()) + } + + single { ApiBinder(get(), get()) } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/KoinInitializer.kt b/app/src/main/java/org/jellyfin/androidtv/di/KoinInitializer.kt new file mode 100644 index 0000000..4658c23 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/KoinInitializer.kt @@ -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 { + override fun create(context: Context): KoinApplication = startKoin { + androidContext(context) + + modules( + androidModule, + appModule, + authModule, + playbackModule, + preferenceModule, + utilsModule, + ) + } + + override fun dependencies() = listOf(LogInitializer::class.java) +} + diff --git a/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt new file mode 100644 index 0000000..57ff01c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/PlaybackModule.kt @@ -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 { RewriteMediaManager(get(), get(), get(), get()) } + + factory { + val preferences = get() + 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() + val api = get() + 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() + defaultRewindAmount = { userSettingPreferences[UserSettingPreferences.skipBackLength].milliseconds } + defaultFastForwardAmount = { userSettingPreferences[UserSettingPreferences.skipForwardLength].milliseconds } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/PreferenceModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/PreferenceModule.kt new file mode 100644 index 0000000..9f9ce00 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/PreferenceModule.kt @@ -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()) } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/di/UtilsModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/UtilsModule.kt new file mode 100644 index 0000000..b55bb61 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/di/UtilsModule.kt @@ -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()) } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/LeanbackChannelWorker.kt b/app/src/main/java/org/jellyfin/androidtv/integration/LeanbackChannelWorker.kt new file mode 100644 index 0000000..0c7c85b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/LeanbackChannelWorker.kt @@ -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() + private val userPreferences by inject() + private val userViewsRepository by inject() + private val imageHelper by inject() + + /** + * 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 { + 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> = + 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, List> = + 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) { + // 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() +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/MediaContentProvider.kt b/app/src/main/java/org/jellyfin/androidtv/integration/MediaContentProvider.kt new file mode 100644 index 0000000..c9e10ad --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/MediaContentProvider.kt @@ -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() + private val imageHelper by inject() + + override fun onCreate(): Boolean = api.isUsable + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + 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?): Int = 0 + override fun update(p0: Uri, p1: ContentValues?, p2: String?, p3: Array?): Int = 0 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt new file mode 100644 index 0000000..6edaa88 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt @@ -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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt new file mode 100644 index 0000000..ffd5b1c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamViewModel.kt @@ -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 + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/LibraryDreamService.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/LibraryDreamService.kt new file mode 100644 index 0000000..7c3a2ac --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/LibraryDreamService.kt @@ -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() + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt new file mode 100644 index 0000000..9a3450f --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLibraryShowcase.kt @@ -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 + ), + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLogo.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLogo.kt new file mode 100644 index 0000000..f61a72e --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentLogo.kt @@ -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() + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt new file mode 100644 index 0000000..cd403a9 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentNowPlaying.kt @@ -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() + val playbackManager = koinInject() + 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) + ) + } + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentVignette.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentVignette.kt new file mode 100644 index 0000000..96690f6 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamContentVignette.kt @@ -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() +) diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt new file mode 100644 index 0000000..d134c53 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHeader.kt @@ -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 + ), + ) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt new file mode 100644 index 0000000..33d266b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt @@ -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() + val userPreferences = koinInject() + val content by viewModel.content.collectAsState() + + DreamView( + content = content, + showClock = when (userPreferences[UserPreferences.clockBehavior]) { + ClockBehavior.ALWAYS, ClockBehavior.IN_MENUS -> true + else -> false + } + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamView.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamView.kt new file mode 100644 index 0000000..163803c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamView.kt @@ -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, + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt new file mode 100644 index 0000000..ea8a5a0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/model/DreamContent.kt @@ -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 +} diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt b/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt new file mode 100644 index 0000000..8770a6a --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt @@ -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() + + override fun onCreate(): Boolean = true + + override fun getType(uri: Uri) = null + override fun query(uri: Uri, projection: Array?, selection: String?, selectionArgs: Array?, sortOrder: String?) = null + override fun insert(uri: Uri, values: ContentValues?) = null + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 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() + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/LibraryPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/LibraryPreferences.kt new file mode 100644 index 0000000..2c499ed --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/LibraryPreferences.kt @@ -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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/LiveTvPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/LiveTvPreferences.kt new file mode 100644 index 0000000..8a49ea3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/LiveTvPreferences.kt @@ -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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/PreferencesRepository.kt b/app/src/main/java/org/jellyfin/androidtv/preference/PreferencesRepository.kt new file mode 100644 index 0000000..d8d9b16 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/PreferencesRepository.kt @@ -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() + + 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() + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/SystemPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/SystemPreferences.kt new file mode 100644 index 0000000..029dcc0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/SystemPreferences.kt @@ -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) + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/TelemetryPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/TelemetryPreferences.kt new file mode 100644 index 0000000..60f3762 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/TelemetryPreferences.kt @@ -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", "") + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt new file mode 100644 index 0000000..c68589b --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/UserPreferences.kt @@ -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) + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/UserSettingPreferences.kt b/app/src/main/java/org/jellyfin/androidtv/preference/UserSettingPreferences.kt new file mode 100644 index 0000000..0eb2883 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/UserSettingPreferences.kt @@ -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 } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/AppTheme.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/AppTheme.kt new file mode 100644 index 0000000..0f4b3c8 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/AppTheme.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/AudioBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/AudioBehavior.kt new file mode 100644 index 0000000..21dfbfa --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/AudioBehavior.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/ClockBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/ClockBehavior.kt new file mode 100644 index 0000000..4970cf1 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/ClockBehavior.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/LiveTvChannelOrder.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/LiveTvChannelOrder.kt new file mode 100644 index 0000000..7e3ffb0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/LiveTvChannelOrder.kt @@ -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 + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/NextUpBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/NextUpBehavior.kt new file mode 100644 index 0000000..d778d81 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/NextUpBehavior.kt @@ -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 diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/RatingType.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/RatingType.kt new file mode 100644 index 0000000..55ea1d5 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/RatingType.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/RefreshRateSwitchingBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/RefreshRateSwitchingBehavior.kt new file mode 100644 index 0000000..3340b05 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/RefreshRateSwitchingBehavior.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/UserSelectBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/UserSelectBehavior.kt new file mode 100644 index 0000000..ea2f447 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/UserSelectBehavior.kt @@ -0,0 +1,7 @@ +package org.jellyfin.androidtv.preference.constant + +enum class UserSelectBehavior { + DISABLED, + LAST_USER, + SPECIFIC_USER +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/WatchedIndicatorBehavior.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/WatchedIndicatorBehavior.kt new file mode 100644 index 0000000..6d7700e --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/WatchedIndicatorBehavior.kt @@ -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), +} diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/constant/ZoomMode.kt b/app/src/main/java/org/jellyfin/androidtv/preference/constant/ZoomMode.kt new file mode 100644 index 0000000..e3f4d6c --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/constant/ZoomMode.kt @@ -0,0 +1,24 @@ +package org.jellyfin.androidtv.preference.constant + +import org.jellyfin.androidtv.R +import org.jellyfin.preference.PreferenceEnum + +enum class ZoomMode( + override val nameRes: Int, +) : PreferenceEnum { + /** + * Sets the zoom mode to normal (fit). + */ + FIT(R.string.lbl_fit), + + /** + * Sets the zoom mode to auto crop. + */ + AUTO_CROP(R.string.lbl_auto_crop), + + /** + * Sets the zoom mode to stretch. + */ + STRETCH(R.string.lbl_stretch), +} + diff --git a/app/src/main/java/org/jellyfin/androidtv/preference/store/DisplayPreferencesStore.kt b/app/src/main/java/org/jellyfin/androidtv/preference/store/DisplayPreferencesStore.kt new file mode 100644 index 0000000..fbea6a4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/preference/store/DisplayPreferencesStore.kt @@ -0,0 +1,152 @@ +package org.jellyfin.androidtv.preference.store + +import org.jellyfin.preference.Preference +import org.jellyfin.preference.PreferenceEnum +import org.jellyfin.preference.migration.MigrationContext +import org.jellyfin.preference.store.AsyncPreferenceStore +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.model.api.DisplayPreferencesDto +import org.jellyfin.sdk.model.api.ScrollDirection +import org.jellyfin.sdk.model.api.SortOrder +import timber.log.Timber + +@Suppress("TooManyFunctions") +abstract class DisplayPreferencesStore( + protected var displayPreferencesId: String, + protected var app: String = "jellyfin-androidtv", + private val api: ApiClient, +) : AsyncPreferenceStore() { + private var displayPreferencesDto: DisplayPreferencesDto? = null + private var cachedPreferences: MutableMap = mutableMapOf() + override val shouldUpdate: Boolean + get() = displayPreferencesDto == null + + override suspend fun commit(): Boolean { + if (displayPreferencesDto == null) return false + + try { + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + client = app, + data = displayPreferencesDto!!.copy( + customPrefs = cachedPreferences + ) + ) + } catch (err: ApiClientException) { + Timber.e(err, "Unable to save displaypreferences. (displayPreferencesId=$displayPreferencesId, app=$app)") + return false + } + + return true + } + + /** + * Clear local copy of display preferences and require an update for new modifications. + */ + fun clearCache(): Boolean { + if (displayPreferencesDto == null) return false + + displayPreferencesDto = null + cachedPreferences.clear() + + return true + } + + override suspend fun update(): Boolean { + try { + val result by api.displayPreferencesApi.getDisplayPreferences( + displayPreferencesId = displayPreferencesId, + client = app + ) + displayPreferencesDto = result + cachedPreferences = result.customPrefs.toMutableMap() + + return true + } catch (err: ApiClientException) { + Timber.e(err, "Unable to retrieve displaypreferences. (displayPreferencesId=$displayPreferencesId, app=$app)") + + if (displayPreferencesDto == null) { + Timber.i("Creating an empty DisplayPreferencesDto for next commit.") + displayPreferencesDto = DisplayPreferencesDto.empty() + } + + return false + } + } + + override fun getInt(key: String, defaultValue: Int) = + cachedPreferences[key]?.toIntOrNull() ?: defaultValue + + override fun getLong(key: String, defaultValue: Long) = + cachedPreferences[key]?.toLongOrNull() ?: defaultValue + + override fun getFloat(key: String, defaultValue: Float) = + cachedPreferences[key]?.toFloatOrNull() ?: defaultValue + + override fun getBool(key: String, defaultValue: Boolean) = + cachedPreferences[key]?.toBooleanStrictOrNull() ?: defaultValue + + override fun getString(key: String, defaultValue: String) = + cachedPreferences[key] ?: defaultValue + + override fun setInt(key: String, value: Int) { + cachedPreferences[key] = value.toString() + } + + override fun setLong(key: String, value: Long) { + cachedPreferences[key] = value.toString() + } + + override fun setFloat(key: String, value: Float) { + cachedPreferences[key] = value.toString() + } + + override fun setBool(key: String, value: Boolean) { + cachedPreferences[key] = value.toString() + } + + override fun setString(key: String, value: String) { + cachedPreferences[key] = value + } + + override fun delete(preference: Preference) { + cachedPreferences.remove(preference.key) + } + + override fun > getEnum(preference: Preference): T { + val stringValue = cachedPreferences[preference.key] + return if (stringValue.isNullOrBlank()) preference.defaultValue + else preference.type.java.enumConstants?.find { + (it is PreferenceEnum && it.serializedName == stringValue) || it.name == stringValue + } ?: preference.defaultValue + } + + override fun > setEnum(preference: Preference<*>, value: Enum) = + setString( + preference.key, when (value) { + is PreferenceEnum -> value.serializedName ?: value.name + else -> value.name + } + ) + + override fun runMigrations(body: MigrationContext.() -> Unit) { + TODO("The DisplayPreferencesStore does not support migrations") + } + + /** + * Create an empty [DisplayPreferencesDto] with default values. + */ + private fun DisplayPreferencesDto.Companion.empty() = DisplayPreferencesDto( + primaryImageHeight = 0, + primaryImageWidth = 0, + customPrefs = emptyMap(), + rememberIndexing = false, + scrollDirection = ScrollDirection.HORIZONTAL, + rememberSorting = false, + showBackdrop = false, + showSidebar = false, + sortOrder = SortOrder.ASCENDING + ) +} diff --git a/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt b/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt new file mode 100644 index 0000000..12b8ae3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/telemetry/TelemetryService.kt @@ -0,0 +1,174 @@ +package org.jellyfin.androidtv.telemetry + +import android.app.Application +import android.content.Context +import org.acra.ACRA +import org.acra.ReportField +import org.acra.config.CoreConfiguration +import org.acra.config.toast +import org.acra.data.CrashReportData +import org.acra.ktx.initAcra +import org.acra.plugins.Plugin +import org.acra.plugins.PluginLoader +import org.acra.plugins.ServicePluginLoader +import org.acra.plugins.SimplePluginLoader +import org.acra.sender.ReportSender +import org.acra.sender.ReportSenderException +import org.acra.sender.ReportSenderFactory +import org.jellyfin.androidtv.BuildConfig +import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.preference.TelemetryPreferences +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import java.net.HttpURLConnection +import java.net.URL + +object TelemetryService { + /** + * Call in the attachBaseContext function of the application. + */ + fun init(context: Application) { + ACRA.DEV_LOGGING = true + context.initAcra { + buildConfigClass = BuildConfig::class.java + sharedPreferencesName = TelemetryPreferences.SHARED_PREFERENCES_NAME + pluginLoader = AcraPluginLoader(AcraReportSenderFactory::class.java) + applicationLogFileLines = 250 + + toast { + text = context.getString(R.string.crash_report_toast) + } + } + } + + class AcraPluginLoader(vararg plugins: Class) : PluginLoader { + private val simplePluginLoader = SimplePluginLoader(*plugins) + private val servicePluginLoader = ServicePluginLoader() + + override fun load(clazz: Class): List = + simplePluginLoader.load(clazz) + servicePluginLoader.load(clazz) + + override fun loadEnabled(config: CoreConfiguration, clazz: Class): List = + simplePluginLoader.loadEnabled(config, clazz) + servicePluginLoader.loadEnabled(config, clazz) + } + + class AcraReportSender( + private val url: String?, + private val token: String?, + private val includeLogs: Boolean, + ) : ReportSender { + override fun send(context: Context, errorContent: CrashReportData) = try { + if (url.isNullOrBlank()) throw ReportSenderException("No telemetry crash report URL available.") + if (token.isNullOrBlank()) throw ReportSenderException("No telemetry crash report token available.") + + // Create connection + val connection = URL(url).openConnection() as HttpURLConnection + // Add authorization + val authorization = AuthorizationHeaderBuilder.buildHeader( + clientName = BuildConfig.APPLICATION_ID, + clientVersion = BuildConfig.VERSION_NAME, + deviceId = "", + deviceName = "", + accessToken = token, + ) + connection.setRequestProperty("Authorization", authorization) + // Write POST body + connection.requestMethod = "POST" + connection.doOutput = true + connection.outputStream.apply { + write(errorContent.toReport().toByteArray()) + flush() + close() + } + // Close + connection.inputStream.close() + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + throw ReportSenderException("Unable to send crash report to server", e) + } + + private fun StringBuilder.appendSection(name: String, content: StringBuilder.() -> Unit) { + appendLine("### $name") + appendLine() + content() + appendLine() + } + + private fun StringBuilder.appendItem(name: String, value: StringBuilder.() -> Unit) { + append("***$name***: ") + value() + appendLine(" ") + } + + private fun StringBuilder.appendCodeBlock(language: String, code: String?) { + appendLine() + appendLine("```$language") + appendLine(code ?: "") + append("```") + } + + private fun StringBuilder.appendValue(value: String?) { + append("`", value ?: "", "`") + } + + private fun CrashReportData.toReport(): String = buildString { + // Header + appendLine("---") + appendLine("client: Jellyfin for Android TV") + appendLine("client_version: ${BuildConfig.VERSION_NAME}") + appendLine("client_repository: https://github.com/jellyfin/jellyfin-androidtv") + appendLine("type: crash_report") + appendLine("format: markdown") + appendLine("---") + + // Content + appendSection("Logs") { + appendItem("Stack Trace") { appendCodeBlock("log", getString(ReportField.STACK_TRACE)) } + appendItem("Logcat") { + if (includeLogs) appendCodeBlock("log", getString(ReportField.LOGCAT)) + else append("Logs are disabled") + } + } + + appendSection("App information") { + appendItem("App version") { + appendValue(getString(ReportField.APP_VERSION_NAME)) + append(" (") + appendValue(getString(ReportField.APP_VERSION_CODE)) + append(")") + } + appendItem("Package name") { appendValue(getString(ReportField.PACKAGE_NAME)) } + appendItem("Build") { appendCodeBlock("json", getString(ReportField.BUILD)) } + appendItem("Build config") { appendCodeBlock("json", getString(ReportField.BUILD_CONFIG)) } + } + + appendSection("Device information") { + appendItem("Android version") { appendValue(getString(ReportField.ANDROID_VERSION)) } + appendItem("Device brand") { appendValue(getString(ReportField.BRAND)) } + appendItem("Device product") { appendValue(getString(ReportField.PRODUCT)) } + appendItem("Device model") { appendValue(getString(ReportField.PHONE_MODEL)) } + } + + appendSection("Crash information") { + appendItem("Start time") { appendValue(getString(ReportField.USER_APP_START_DATE)) } + appendItem("Crash time") { appendValue(getString(ReportField.USER_CRASH_DATE)) } + } + + // Dump + if (BuildConfig.DEVELOPMENT) { + appendSection("Dump") { + appendCodeBlock("json", toJSON()) + } + } + } + } + + class AcraReportSenderFactory : ReportSenderFactory { + override fun create(context: Context, config: CoreConfiguration): ReportSender { + val preferences = context.getSharedPreferences(TelemetryPreferences.SHARED_PREFERENCES_NAME, Context.MODE_PRIVATE) + val url = preferences?.getString(TelemetryPreferences.crashReportUrl.key, null) + val token = preferences?.getString(TelemetryPreferences.crashReportToken.key, null) + val includeLogs = preferences?.getBoolean(TelemetryPreferences.crashReportIncludeLogs.key, true) ?: true + + return AcraReportSender(url, token, includeLogs) + } + } +} diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/AlphaPickerView.kt b/app/src/main/java/org/jellyfin/androidtv/ui/AlphaPickerView.kt new file mode 100644 index 0000000..80ee61d --- /dev/null +++ b/app/src/main/java/org/jellyfin/androidtv/ui/AlphaPickerView.kt @@ -0,0 +1,50 @@ +package org.jellyfin.androidtv.ui + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.Button +import android.widget.HorizontalScrollView +import android.widget.LinearLayout +import androidx.core.view.children +import org.jellyfin.androidtv.R +import org.jellyfin.androidtv.databinding.ViewButtonAlphaPickerBinding + +class AlphaPickerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, + defStyleRes: Int = 0, +) : HorizontalScrollView(context, attrs, defStyleAttr, defStyleRes) { + var onAlphaSelected: (letter: Char) -> Unit = {} + + init { + isFocusable = false + isFocusableInTouchMode = false + isHorizontalScrollBarEnabled = false + + val layout = LinearLayout(context) + + val letters = "#${resources.getString(R.string.byletter_letters)}" + letters.forEach { letter -> + val binding = ViewButtonAlphaPickerBinding.inflate(LayoutInflater.from(context), this, false) + binding.button.apply { + text = letter.toString() + setOnClickListener { _ -> + onAlphaSelected(letter) + } + } + + layout.addView(binding.root) + } + + addView(layout) + } + + fun focus(letter: Char) { + children + .filterIsInstance