Source Code added
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run
Some checks are pending
Repo / Label merge conflict / Triage (push) Waiting to run
This commit is contained in:
parent
ac679f452a
commit
3f20680501
477 changed files with 25051 additions and 2 deletions
213
app/build.gradle.kts
Normal file
213
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import io.gitlab.arturbosch.detekt.Detekt
|
||||
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.app)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.ksp)
|
||||
alias(libs.plugins.kotlin.parcelize)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.detekt)
|
||||
alias(libs.plugins.android.junit5)
|
||||
}
|
||||
|
||||
detekt {
|
||||
buildUponDefaultConfig = true
|
||||
allRules = false
|
||||
config = files("${rootProject.projectDir}/detekt.yml")
|
||||
autoCorrect = true
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.jellyfin.mobile"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 21
|
||||
targetSdk = 34
|
||||
versionName = project.getVersionName()
|
||||
versionCode = getVersionCode(versionName!!)
|
||||
setProperty("archivesBaseName", "jellyfin-android-v$versionName")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
val releaseSigningConfig = SigningHelper.loadSigningConfig(project)?.let { config ->
|
||||
signingConfigs.create("release") {
|
||||
storeFile = config.storeFile
|
||||
storePassword = config.storePassword
|
||||
keyAlias = config.keyAlias
|
||||
keyPassword = config.keyPassword
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
aaptOptions.cruncherEnabled = false
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
signingConfig = releaseSigningConfig
|
||||
}
|
||||
getByName("debug") {
|
||||
applicationIdSuffix = ".debug"
|
||||
isDebuggable = true
|
||||
aaptOptions.cruncherEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "variant"
|
||||
productFlavors {
|
||||
register("libre") {
|
||||
dimension = "variant"
|
||||
buildConfigField("boolean", "IS_PROPRIETARY", "false")
|
||||
}
|
||||
register("proprietary") {
|
||||
dimension = "variant"
|
||||
buildConfigField("boolean", "IS_PROPRIETARY", "true")
|
||||
isDefault = true
|
||||
}
|
||||
}
|
||||
|
||||
bundle {
|
||||
language {
|
||||
enableSplit = false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
viewBinding = true
|
||||
compose = true
|
||||
}
|
||||
kotlinOptions {
|
||||
@Suppress("SuspiciousCollectionReassignment")
|
||||
freeCompilerArgs += listOf("-Xopt-in=kotlin.RequiresOptIn")
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
lint {
|
||||
lintConfig = file("$rootDir/android-lint.xml")
|
||||
abortOnError = false
|
||||
sarifReport = true
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
arg("room.incremental", "true")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val proprietaryImplementation by configurations
|
||||
|
||||
// Kotlin
|
||||
implementation(libs.bundles.coroutines)
|
||||
|
||||
// Core
|
||||
implementation(libs.bundles.koin)
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.fragment)
|
||||
coreLibraryDesugaring(libs.androiddesugarlibs)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
|
||||
// UI
|
||||
implementation(libs.google.material)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.androidx.webkit)
|
||||
implementation(libs.modernandroidpreferences)
|
||||
|
||||
// Jetpack Compose
|
||||
implementation(libs.bundles.compose)
|
||||
|
||||
// Network
|
||||
val sdkVersion = findProperty("sdk.version")?.toString()
|
||||
implementation(libs.jellyfin.sdk) {
|
||||
// Change version if desired
|
||||
when (sdkVersion) {
|
||||
"local" -> version { strictly(JellyfinSdk.LOCAL) }
|
||||
"snapshot" -> version { strictly(JellyfinSdk.SNAPSHOT) }
|
||||
"unstable-snapshot" -> version { strictly(JellyfinSdk.SNAPSHOT_UNSTABLE) }
|
||||
}
|
||||
}
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.coil)
|
||||
implementation(libs.cronet.embedded)
|
||||
|
||||
// Media
|
||||
implementation(libs.androidx.media)
|
||||
implementation(libs.androidx.mediarouter)
|
||||
implementation(libs.bundles.exoplayer) {
|
||||
// Exclude Play Services cronet provider library
|
||||
exclude("com.google.android.gms", "play-services-cronet")
|
||||
}
|
||||
implementation(libs.jellyfin.exoplayer.ffmpegextension)
|
||||
proprietaryImplementation(libs.exoplayer.cast)
|
||||
proprietaryImplementation(libs.bundles.playservices)
|
||||
|
||||
// Room
|
||||
implementation(libs.bundles.androidx.room)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// Monitoring
|
||||
implementation(libs.timber)
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit.api)
|
||||
testRuntimeOnly(libs.junit.engine)
|
||||
testImplementation(libs.bundles.kotest)
|
||||
testImplementation(libs.mockk)
|
||||
androidTestImplementation(libs.bundles.androidx.test)
|
||||
|
||||
// Formatting rules for detekt
|
||||
detektPlugins(libs.detekt.formatting)
|
||||
}
|
||||
|
||||
tasks {
|
||||
withType<KotlinCompile> {
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
}
|
||||
}
|
||||
|
||||
withType<Detekt> {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
|
||||
reports {
|
||||
html.required.set(true)
|
||||
xml.required.set(false)
|
||||
txt.required.set(true)
|
||||
sarif.required.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Testing
|
||||
withType<Test> {
|
||||
useJUnitPlatform()
|
||||
testLogging {
|
||||
outputs.upToDateWhen { false }
|
||||
showStandardStreams = true
|
||||
}
|
||||
}
|
||||
|
||||
register("versionTxt") {
|
||||
val path = buildDir.resolve("version.txt")
|
||||
|
||||
doLast {
|
||||
val versionString = "v${android.defaultConfig.versionName}=${android.defaultConfig.versionCode}"
|
||||
println("Writing [$versionString] to $path")
|
||||
path.writeText("$versionString\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/proguard-rules.pro
vendored
Normal file
34
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Keep names of all Jellyfin classes
|
||||
-keepnames class org.jellyfin.mobile.**.* { *; }
|
||||
-keepnames interface org.jellyfin.mobile.**.* { *; }
|
||||
|
||||
# Keep WebView JS interfaces
|
||||
-keepclassmembers class org.jellyfin.mobile.bridge.* {
|
||||
@android.webkit.JavascriptInterface public *;
|
||||
}
|
||||
|
||||
# Keep Chromecast methods
|
||||
-keepclassmembers class org.jellyfin.mobile.player.cast.Chromecast {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Keep file names/line numbers
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
|
||||
# Keep custom exceptions
|
||||
-keep public class * extends java.lang.Exception
|
||||
|
||||
# Keep AndroidX ComponentFactory
|
||||
-keep class androidx.core.app.CoreComponentFactory { *; }
|
||||
|
||||
# Assume SDK >= 21 to remove unnecessary compat code
|
||||
-assumevalues class android.os.Build$VERSION {
|
||||
int SDK_INT return 21..2147483647;
|
||||
}
|
||||
94
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json
Normal file
94
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/1.json
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "1888633e841bb503cfc73de0c979f8fc",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Server",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostname",
|
||||
"columnName": "hostname",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedTimestamp",
|
||||
"columnName": "last_used_timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Server_hostname",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"hostname"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, PRIMARY KEY(`server_id`, `user_id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "server_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessToken",
|
||||
"columnName": "access_token",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastLoginTimestamp",
|
||||
"columnName": "last_login_timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"server_id",
|
||||
"user_id"
|
||||
],
|
||||
"autoGenerate": false
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1888633e841bb503cfc73de0c979f8fc')"
|
||||
]
|
||||
}
|
||||
}
|
||||
123
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
Normal file
123
app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "b88354b3000c5abb5c19bfea2813d43a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Server",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hostname",
|
||||
"columnName": "hostname",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastUsedTimestamp",
|
||||
"columnName": "last_used_timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_Server_hostname",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"hostname"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "User",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `Server`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "serverId",
|
||||
"columnName": "server_id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "user_id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "accessToken",
|
||||
"columnName": "access_token",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastLoginTimestamp",
|
||||
"columnName": "last_login_timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_User_server_id_user_id",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"server_id",
|
||||
"user_id"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_User_server_id_user_id` ON `${TABLE_NAME}` (`server_id`, `user_id`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
{
|
||||
"table": "Server",
|
||||
"onDelete": "NO ACTION",
|
||||
"onUpdate": "NO ACTION",
|
||||
"columns": [
|
||||
"server_id"
|
||||
],
|
||||
"referencedColumns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b88354b3000c5abb5c19bfea2813d43a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
8
app/src/debug/res/color-v24/splash_fill.xml
Normal file
8
app/src/debug/res/color-v24/splash_fill.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<gradient xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:endColor="#fdc92f"
|
||||
android:endX="752"
|
||||
android:endY="692"
|
||||
android:startColor="#f2364d"
|
||||
android:startX="366"
|
||||
android:startY="469"
|
||||
android:type="linear" />
|
||||
46
app/src/debug/res/drawable/app_logo.xml
Normal file
46
app/src/debug/res/drawable/app_logo.xml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="252dp"
|
||||
android:height="72dp"
|
||||
android:viewportWidth="252"
|
||||
android:viewportHeight="72">
|
||||
<path android:pathData="M24.71,49.16c-1.55,-3.12 8.63,-21.57 11.79,-21.57 3.17,0 13.32,18.49 11.79,21.57 -1.53,3.08 -22.02,3.12 -23.58,0z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="72.5"
|
||||
android:endY="63"
|
||||
android:startX="12.5"
|
||||
android:startY="30"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#F2364D"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#FDC92F"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M0.98,65C-3.69,55.61 26.98,0 36.5,0c9.53,0 40.15,55.71 35.53,65s-66.37,9.39 -71.04,0m12.26,-8.15c3.07,6.15 43.52,6.08 46.55,0 3.03,-6.09 -17.03,-42.59 -23.27,-42.59S10.17,50.69 13.23,56.85z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="72.5"
|
||||
android:endY="63"
|
||||
android:startX="12.5"
|
||||
android:startY="30"
|
||||
android:tileMode="clamp">
|
||||
<item
|
||||
android:color="#F2364D"
|
||||
android:offset="0" />
|
||||
<item
|
||||
android:color="#FDC92F"
|
||||
android:offset="1" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFF"
|
||||
android:pathData="M143.24,14.25c-0.28,0 -0.42,0 -0.52,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L142.44,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.13,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L149.67,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM154.86,14.25c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53L154.06,55.2c0,0.28 0,0.42 0.05,0.53 0.05,0.09 0.12,0.17 0.22,0.22 0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L161.28,15.06c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM208.55,14.25q-5.18,0 -8.27,2.81 -3.09,2.81 -3.09,7.94L197.19,26h-10.17c-0.3,0 -0.45,0 -0.58,0.05a0.75,0.75 0,0 0,-0.29 0.21c-0.09,0.1 -0.14,0.24 -0.25,0.52l-7.43,19.9 -7.48,-19.9c-0.1,-0.28 -0.16,-0.42 -0.25,-0.52a0.76,0.76 0,0 0,-0.3 -0.2c-0.13,-0.05 -0.28,-0.05 -0.58,-0.05h-5.77c-0.39,0 -0.59,0 -0.72,0.08a0.5,0.5 0,0 0,-0.21 0.31c-0.03,0.15 0.04,0.33 0.19,0.7L174.78,56l-0.66,1.6q-0.88,1.87 -2.04,3.03 -1.1,1.16 -3.58,1.16 -0.88,0 -1.88,-0.17a13,13 0,0 1,-0.73 -0.1c-0.39,-0.06 -0.58,-0.1 -0.71,-0.05a0.47,0.47 0,0 0,-0.26 0.22c-0.07,0.12 -0.07,0.3 -0.07,0.66v4.33c0,0.24 0,0.37 0.05,0.48a0.74,0.74 0,0 0,0.19 0.27c0.09,0.08 0.19,0.12 0.39,0.18a8,8 0,0 0,1.47 0.35q1.16,0.22 2.37,0.22 4.24,0 7.06,-2.43 2.87,-2.37 4.58,-6.73l10.52,-26.58h5.72v22.75c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.52,0.05h5.63c0.28,0 0.42,-0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L204.42,32.45h5.87c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L211.09,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05h-5.87v-0.99q0,-2.26 1.32,-3.31 1.38,-1.1 3.75,-1.1 0.46,0 0.94,0.05c0.34,0.03 0.52,0.05 0.63,-0a0.48,0.48 0,0 0,0.24 -0.22c0.06,-0.11 0.06,-0.28 0.06,-0.6v-4.43c0,-0.3 0,-0.46 -0.06,-0.59a0.7,0.7 0,0 0,-0.25 -0.29c-0.12,-0.08 -0.26,-0.1 -0.54,-0.13a14,14 0,0 0,-1.97 -0.13zM99.46,14.92c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v27.82q0,2.54 -1.6,4.08 -1.54,1.49 -4.19,1.49h-1.57c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v5.29c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h1.57q4.08,0 7.06,-1.6 3.03,-1.6 4.63,-4.47 1.65,-2.87 1.66,-6.67L106.22,15.72c0,-0.28 -0,-0.42 -0.06,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM213.99,14.92c-0.16,0 -0.26,0.01 -0.34,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.52v6.12c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-6.12c0,-0.28 0,-0.42 -0.05,-0.52a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.52,-0.05zM124.56,25.34q-4.19,0 -7.61,2.04 -3.36,2.04 -5.35,5.57 -1.93,3.47 -1.93,8 0,4.36 1.93,7.94c1.93,3.59 3.09,4.28 5.4,5.68q3.47,2.1 8.11,2.1 4.58,0 8,-2.04 3.09,-1.87 4.53,-4.61c0.11,-0.22 0.17,-0.33 0.17,-0.45a0.53,0.53 0,0 0,-0.1 -0.3c-0.07,-0.1 -0.19,-0.16 -0.43,-0.27l-4.27,-2.09c-0.31,-0.15 -0.47,-0.23 -0.61,-0.24a0.6,0.6 0,0 0,-0.35 0.08c-0.12,0.07 -0.24,0.22 -0.48,0.54a8.2,8.2 0,0 1,-2.21 1.99q-1.71,1.05 -4.19,1.05 -3.26,0 -5.46,-1.98 -2.2,-1.99 -2.54,-5.3h21.06c0.2,0 0.3,0 0.39,-0.04a0.55,0.55 0,0 0,0.21 -0.17c0.06,-0.08 0.08,-0.17 0.12,-0.33q0.09,-0.39 0.12,-0.84 0.11,-0.83 0.11,-1.65 0,-4.03 -1.71,-7.33t-4.96,-5.3q-3.26,-2.04 -7.94,-2.04m115.64,0q-2.81,0 -5.07,1.1a7.9,7.9 0,0 0,-3.42 3.25L231.71,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05h-5.18c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.62c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53v-16.79q0,-2.92 1.65,-4.69 1.71,-1.77 4.41,-1.77 2.7,0 4.36,1.77 1.71,1.71 1.71,4.69v16.79c0,0.28 -0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.53,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.06,-0.25 0.06,-0.53v-18.5q-0,-3.37 -1.44,-5.9a10.1,10.1 0,0 0,-4.03 -4.03q-2.54,-1.43 -5.85,-1.43M214.18,26c-0.28,0 -0.42,0 -0.53,0.05a0.5,0.5 0,0 0,-0.22 0.22c-0.05,0.11 -0.05,0.25 -0.05,0.53v28.4c0,0.28 0,0.42 0.05,0.53a0.5,0.5 0,0 0,0.22 0.22c0.11,0.05 0.25,0.05 0.53,0.05h5.63c0.28,0 0.42,0 0.52,-0.05a0.5,0.5 0,0 0,0.22 -0.22c0.05,-0.11 0.05,-0.25 0.05,-0.53L220.61,26.8c0,-0.28 0,-0.42 -0.05,-0.53a0.5,0.5 0,0 0,-0.22 -0.22c-0.11,-0.05 -0.25,-0.05 -0.53,-0.05zM124.56,31.3q2.87,0 4.74,1.76 1.93,1.71 2.15,4.47h-14.12q0.61,-2.98 2.54,-4.58 1.99,-1.65 4.69,-1.65" />
|
||||
</vector>
|
||||
19
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
19
app/src/debug/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<path android:pathData="m512 284.789466c-57.344 0-241.850182 334.568734-213.736727 391.074914 28.113454 56.50618 399.639277 55.85454 427.473457 0 27.83418-55.85455-156.39273-391.074914-213.73673-391.074914zm140.10182 342.109094c-18.24582 36.58473-261.67855 37.05018-280.11055 0-18.431997-37.05018 102.49309-256.27928 140.00873-256.27928s158.34764 219.60146 140.10182 256.27928zm-140.10182-176.128c-18.99055 0-80.24436 111.05745-70.93527 129.76873 9.30909 18.71127 132.65454 18.52509 141.87054 0s-51.85163-129.76873-70.93527-129.76873z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endColor="#FDC92F"
|
||||
android:endX="752"
|
||||
android:endY="692"
|
||||
android:startColor="#F2364D"
|
||||
android:startX="366"
|
||||
android:startY="469"
|
||||
android:type="linear" />
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
4
app/src/debug/res/values/colors.xml
Normal file
4
app/src/debug/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#000000</color>
|
||||
</resources>
|
||||
4
app/src/debug/res/values/strings_donottranslate.xml
Normal file
4
app/src/debug/res/values/strings_donottranslate.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">Jellyfin Debug</string>
|
||||
</resources>
|
||||
9
app/src/libre/AndroidManifest.xml
Normal file
9
app/src/libre/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import com.google.android.exoplayer2.Player
|
||||
import org.jellyfin.mobile.player.audio.MediaService
|
||||
|
||||
class CastPlayerProvider(@Suppress("UNUSED_PARAMETER") mediaService: MediaService) : ICastPlayerProvider {
|
||||
override val isCastSessionAvailable: Boolean = false
|
||||
|
||||
override fun get(): Player? = null
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import android.app.Activity
|
||||
import org.jellyfin.mobile.bridge.JavascriptCallback
|
||||
import org.json.JSONArray
|
||||
|
||||
class Chromecast : IChromecast {
|
||||
override fun initializePlugin(activity: Activity) = Unit
|
||||
override fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback) = false
|
||||
override fun destroy() = Unit
|
||||
}
|
||||
108
app/src/main/AndroidManifest.xml
Normal file
108
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:installLocation="auto">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30"
|
||||
android:required="false" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_CONNECT"
|
||||
android:required="false"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
tools:ignore="ScopedStorage"
|
||||
tools:node="remove" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
|
||||
<package android:name="com.mxtech.videoplayer.ad" />
|
||||
<package android:name="com.mxtech.videoplayer.pro" />
|
||||
<package android:name="is.xyz.mpv" />
|
||||
<package android:name="org.videolan.vlc" />
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".JellyfinApplication"
|
||||
android:allowBackup="true"
|
||||
android:fullBackupContent="@xml/backup_content"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme.Starting"
|
||||
tools:ignore="UnusedAttribute">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation|keyboard|keyboardHidden|navigation|uiMode"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:supportsPictureInPicture="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<!-- declare legacy support for voice actions -->
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<service
|
||||
android:name=".webapp.RemotePlayerService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name="org.jellyfin.mobile.player.audio.MediaService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name="androidx.mediarouter.media.MediaTransferReceiver"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedReceiver" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.version"
|
||||
android:value="@integer/google_play_services_version" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="org.jellyfin.mobile.player.cast.CastOptionsProvider" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher_round" />
|
||||
<meta-data
|
||||
android:name="android.webkit.WebView.EnableSafeBrowsing"
|
||||
android:value="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
442
app/src/main/assets/native/EventEmitter.js
Normal file
442
app/src/main/assets/native/EventEmitter.js
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/*!
|
||||
* EventEmitter v4.2.11 - git.io/ee
|
||||
* Unlicense - http://unlicense.org/
|
||||
* Oliver Caldwell - http://oli.me.uk/
|
||||
* @preserve
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Class for managing events.
|
||||
* Can be extended to provide event functionality in other classes.
|
||||
*
|
||||
* @class EventEmitter Manages event registering and emitting.
|
||||
*/
|
||||
function EventEmitter () {}
|
||||
|
||||
// Shortcuts to improve speed and size
|
||||
var proto = EventEmitter.prototype;
|
||||
|
||||
/**
|
||||
* Finds the index of the listener for the event in its storage array.
|
||||
*
|
||||
* @param {Function[]} listeners Array of listeners to search through.
|
||||
* @param {Function} listener Method to look for.
|
||||
* @return {Number} Index of the specified listener, -1 if not found
|
||||
* @api private
|
||||
*/
|
||||
function indexOfListener (listeners, listener) {
|
||||
var i = listeners.length;
|
||||
while (i--) {
|
||||
if (listeners[i].listener === listener) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias a method while keeping the context correct, to allow for overwriting of target method.
|
||||
*
|
||||
* @param {String} name The name of the target method.
|
||||
* @return {Function} The aliased method
|
||||
* @api private
|
||||
*/
|
||||
function alias (name) {
|
||||
return function aliasClosure () {
|
||||
return this[name].apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the listener array for the specified event.
|
||||
* Will initialise the event object and listener arrays if required.
|
||||
* Will return an object if you use a regex search. The object contains keys for each matched event. So /ba[rz]/ might return an object containing bar and baz. But only if you have either defined them with defineEvent or added some listeners to them.
|
||||
* Each property in the object response is an array of listener functions.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to return the listeners from.
|
||||
* @return {Function[]|Object} All listener functions for the event.
|
||||
*/
|
||||
proto.getListeners = function getListeners (evt) {
|
||||
var events = this._getEvents();
|
||||
var response;
|
||||
var key;
|
||||
|
||||
// Return a concatenated array of all matching events if
|
||||
// the selector is a regular expression.
|
||||
if (evt instanceof RegExp) {
|
||||
response = {};
|
||||
for (key in events) {
|
||||
if (events.hasOwnProperty(key) && evt.test(key)) {
|
||||
response[key] = events[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response = events[evt] || (events[evt] = []);
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes a list of listener objects and flattens it into a list of listener functions.
|
||||
*
|
||||
* @param {Object[]} listeners Raw listener objects.
|
||||
* @return {Function[]} Just the listener functions.
|
||||
*/
|
||||
proto.flattenListeners = function flattenListeners (listeners) {
|
||||
var flatListeners = [];
|
||||
var i;
|
||||
|
||||
for (i = 0; i < listeners.length; i += 1) {
|
||||
flatListeners.push(listeners[i].listener);
|
||||
}
|
||||
|
||||
return flatListeners;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the requested listeners via getListeners but will always return the results inside an object. This is mainly for internal use but others may find it useful.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to return the listeners from.
|
||||
* @return {Object} All listener functions for an event in an object.
|
||||
*/
|
||||
proto.getListenersAsObject = function getListenersAsObject (evt) {
|
||||
var listeners = this.getListeners(evt);
|
||||
var response;
|
||||
|
||||
if (listeners instanceof Array) {
|
||||
response = {};
|
||||
response[evt] = listeners;
|
||||
}
|
||||
|
||||
return response || listeners;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a listener function to the specified event.
|
||||
* The listener will not be added if it is a duplicate.
|
||||
* If the listener returns true then it will be removed after it is called.
|
||||
* If you pass a regular expression as the event name then the listener will be added to all events that match it.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to attach the listener to.
|
||||
* @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.addListener = function addListener (evt, listener) {
|
||||
var listeners = this.getListenersAsObject(evt);
|
||||
var listenerIsWrapped = typeof listener === 'object';
|
||||
var key;
|
||||
|
||||
for (key in listeners) {
|
||||
if (listeners.hasOwnProperty(key) && indexOfListener(listeners[key], listener) === -1) {
|
||||
listeners[key].push(listenerIsWrapped ? listener : {
|
||||
listener: listener,
|
||||
once: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Alias of addListener
|
||||
*/
|
||||
proto.on = alias('addListener');
|
||||
|
||||
/**
|
||||
* Semi-alias of addListener. It will add a listener that will be
|
||||
* automatically removed after its first execution.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to attach the listener to.
|
||||
* @param {Function} listener Method to be called when the event is emitted. If the function returns true then it will be removed after calling.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.addOnceListener = function addOnceListener (evt, listener) {
|
||||
return this.addListener(evt, {
|
||||
listener: listener,
|
||||
once: true
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Alias of addOnceListener.
|
||||
*/
|
||||
proto.once = alias('addOnceListener');
|
||||
|
||||
/**
|
||||
* Defines an event name. This is required if you want to use a regex to add a listener to multiple events at once. If you don't do this then how do you expect it to know what event to add to? Should it just add to every possible match for a regex? No. That is scary and bad.
|
||||
* You need to tell it what event names should be matched by a regex.
|
||||
*
|
||||
* @param {String} evt Name of the event to create.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.defineEvent = function defineEvent (evt) {
|
||||
this.getListeners(evt);
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Uses defineEvent to define multiple events.
|
||||
*
|
||||
* @param {String[]} evts An array of event names to define.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.defineEvents = function defineEvents (evts) {
|
||||
for (var i = 0; i < evts.length; i += 1) {
|
||||
this.defineEvent(evts[i]);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a listener function from the specified event.
|
||||
* When passed a regular expression as the event name, it will remove the listener from all events that match it.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to remove the listener from.
|
||||
* @param {Function} listener Method to remove from the event.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.removeListener = function removeListener (evt, listener) {
|
||||
var listeners = this.getListenersAsObject(evt);
|
||||
var index;
|
||||
var key;
|
||||
|
||||
for (key in listeners) {
|
||||
if (listeners.hasOwnProperty(key)) {
|
||||
index = indexOfListener(listeners[key], listener);
|
||||
|
||||
if (index !== -1) {
|
||||
listeners[key].splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Alias of removeListener
|
||||
*/
|
||||
proto.off = alias('removeListener');
|
||||
|
||||
/**
|
||||
* Adds listeners in bulk using the manipulateListeners method.
|
||||
* If you pass an object as the second argument you can add to multiple events at once. The object should contain key value pairs of events and listeners or listener arrays. You can also pass it an event name and an array of listeners to be added.
|
||||
* You can also pass it a regular expression to add the array of listeners to all events that match it.
|
||||
* Yeah, this function does quite a bit. That's probably a bad thing.
|
||||
*
|
||||
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add to multiple events at once.
|
||||
* @param {Function[]} [listeners] An optional array of listener functions to add.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.addListeners = function addListeners (evt, listeners) {
|
||||
// Pass through to manipulateListeners
|
||||
return this.manipulateListeners(false, evt, listeners);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes listeners in bulk using the manipulateListeners method.
|
||||
* If you pass an object as the second argument you can remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
|
||||
* You can also pass it an event name and an array of listeners to be removed.
|
||||
* You can also pass it a regular expression to remove the listeners from all events that match it.
|
||||
*
|
||||
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to remove from multiple events at once.
|
||||
* @param {Function[]} [listeners] An optional array of listener functions to remove.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.removeListeners = function removeListeners (evt, listeners) {
|
||||
// Pass through to manipulateListeners
|
||||
return this.manipulateListeners(true, evt, listeners);
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits listeners in bulk. The addListeners and removeListeners methods both use this to do their job. You should really use those instead, this is a little lower level.
|
||||
* The first argument will determine if the listeners are removed (true) or added (false).
|
||||
* If you pass an object as the second argument you can add/remove from multiple events at once. The object should contain key value pairs of events and listeners or listener arrays.
|
||||
* You can also pass it an event name and an array of listeners to be added/removed.
|
||||
* You can also pass it a regular expression to manipulate the listeners of all events that match it.
|
||||
*
|
||||
* @param {Boolean} remove True if you want to remove listeners, false if you want to add.
|
||||
* @param {String|Object|RegExp} evt An event name if you will pass an array of listeners next. An object if you wish to add/remove from multiple events at once.
|
||||
* @param {Function[]} [listeners] An optional array of listener functions to add/remove.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.manipulateListeners = function manipulateListeners (remove, evt, listeners) {
|
||||
var i;
|
||||
var value;
|
||||
var single = remove ? this.removeListener : this.addListener;
|
||||
var multiple = remove ? this.removeListeners : this.addListeners;
|
||||
|
||||
// If evt is an object then pass each of its properties to this method
|
||||
if (typeof evt === 'object' && !(evt instanceof RegExp)) {
|
||||
for (i in evt) {
|
||||
if (evt.hasOwnProperty(i) && (value = evt[i])) {
|
||||
// Pass the single listener straight through to the singular method
|
||||
if (typeof value === 'function') {
|
||||
single.call(this, i, value);
|
||||
} else {
|
||||
// Otherwise pass back to the multiple function
|
||||
multiple.call(this, i, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// So evt must be a string
|
||||
// And listeners must be an array of listeners
|
||||
// Loop over it and pass each one to the multiple method
|
||||
i = listeners.length;
|
||||
while (i--) {
|
||||
single.call(this, evt, listeners[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes all listeners from a specified event.
|
||||
* If you do not specify an event then all listeners will be removed.
|
||||
* That means every event will be emptied.
|
||||
* You can also pass a regex to remove all events that match it.
|
||||
*
|
||||
* @param {String|RegExp} [evt] Optional name of the event to remove all listeners for. Will remove from every event if not passed.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.removeEvent = function removeEvent (evt) {
|
||||
var type = typeof evt;
|
||||
var events = this._getEvents();
|
||||
var key;
|
||||
|
||||
// Remove different things depending on the state of evt
|
||||
if (type === 'string') {
|
||||
// Remove all listeners for the specified event
|
||||
delete events[evt];
|
||||
} else if (evt instanceof RegExp) {
|
||||
// Remove all events matching the regex.
|
||||
for (key in events) {
|
||||
if (events.hasOwnProperty(key) && evt.test(key)) {
|
||||
delete events[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remove all listeners in all events
|
||||
delete this._events;
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Alias of removeEvent.
|
||||
*
|
||||
* Added to mirror the node API.
|
||||
*/
|
||||
proto.removeAllListeners = alias('removeEvent');
|
||||
|
||||
/**
|
||||
* Emits an event of your choice.
|
||||
* When emitted, every listener attached to that event will be executed.
|
||||
* If you pass the optional argument array then those arguments will be passed to every listener upon execution.
|
||||
* Because it uses `apply`, your array of arguments will be passed as if you wrote them out separately.
|
||||
* So they will not arrive within the array on the other side, they will be separate.
|
||||
* You can also pass a regular expression to emit to all events that match it.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to emit and execute listeners for.
|
||||
* @param {Array} [args] Optional array of arguments to be passed to each listener.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.emitEvent = function emitEvent (evt, args) {
|
||||
var listeners = this.getListenersAsObject(evt);
|
||||
var listener;
|
||||
var i;
|
||||
var key;
|
||||
var response;
|
||||
|
||||
for (key in listeners) {
|
||||
if (listeners.hasOwnProperty(key)) {
|
||||
i = listeners[key].length;
|
||||
|
||||
while (i--) {
|
||||
// If the listener returns true then it shall be removed from the event
|
||||
// The function is executed either with a basic call or an apply if there is an args array
|
||||
listener = listeners[key][i];
|
||||
|
||||
if (listener.once === true) {
|
||||
this.removeListener(evt, listener.listener);
|
||||
}
|
||||
|
||||
response = listener.listener.apply(this, args || []);
|
||||
|
||||
if (response === this._getOnceReturnValue()) {
|
||||
this.removeListener(evt, listener.listener);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Alias of emitEvent
|
||||
*/
|
||||
proto.trigger = alias('emitEvent');
|
||||
|
||||
/**
|
||||
* Subtly different from emitEvent in that it will pass its arguments on to the listeners, as opposed to taking a single array of arguments to pass on.
|
||||
* As with emitEvent, you can pass a regex in place of the event name to emit to all events that match it.
|
||||
*
|
||||
* @param {String|RegExp} evt Name of the event to emit and execute listeners for.
|
||||
* @param {...*} Optional additional arguments to be passed to each listener.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.emit = function emit (evt) {
|
||||
var args = Array.prototype.slice.call(arguments, 1);
|
||||
return this.emitEvent(evt, args);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the current value to check against when executing listeners. If a
|
||||
* listeners return value matches the one set here then it will be removed
|
||||
* after execution. This value defaults to true.
|
||||
*
|
||||
* @param {*} value The new value to check for when executing listeners.
|
||||
* @return {Object} Current instance of EventEmitter for chaining.
|
||||
*/
|
||||
proto.setOnceReturnValue = function setOnceReturnValue (value) {
|
||||
this._onceReturnValue = value;
|
||||
return this;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the current value to check against when executing listeners. If
|
||||
* the listeners return value matches this one then it should be removed
|
||||
* automatically. It will return true by default.
|
||||
*
|
||||
* @return {*|Boolean} The current value to check for or the default, true.
|
||||
* @api private
|
||||
*/
|
||||
proto._getOnceReturnValue = function _getOnceReturnValue () {
|
||||
if (this.hasOwnProperty('_onceReturnValue')) {
|
||||
return this._onceReturnValue;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches the events object and creates one if required.
|
||||
*
|
||||
* @return {Object} The events storage object.
|
||||
* @api private
|
||||
*/
|
||||
proto._getEvents = function _getEvents () {
|
||||
return this._events || (this._events = {});
|
||||
};
|
||||
|
||||
window.CastPluginEventEmitter = EventEmitter;
|
||||
}());
|
||||
179
app/src/main/assets/native/ExoPlayerPlugin.js
Normal file
179
app/src/main/assets/native/ExoPlayerPlugin.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
export class ExoPlayerPlugin {
|
||||
constructor({ events, playbackManager, loading }) {
|
||||
window['ExoPlayer'] = this;
|
||||
|
||||
this.events = events;
|
||||
this.playbackManager = playbackManager;
|
||||
this.loading = loading;
|
||||
|
||||
this.name = 'ExoPlayer';
|
||||
this.type = 'mediaplayer';
|
||||
this.id = 'exoplayer';
|
||||
|
||||
// Prioritize first
|
||||
this.priority = -1;
|
||||
this.isLocalPlayer = true;
|
||||
|
||||
// Current playback position in milliseconds
|
||||
this._currentTime = 0;
|
||||
this._paused = true;
|
||||
|
||||
this._nativePlayer = window['NativePlayer'];
|
||||
}
|
||||
|
||||
async play(options) {
|
||||
// Sanitize input
|
||||
options.ids = options.items.map(item => item.Id);
|
||||
delete options.items;
|
||||
|
||||
this._paused = false;
|
||||
this._nativePlayer.loadPlayer(JSON.stringify(options));
|
||||
this.loading.hide();
|
||||
}
|
||||
|
||||
shuffle(item) {}
|
||||
|
||||
instantMix(item) {}
|
||||
|
||||
queue(options) {}
|
||||
|
||||
queueNext(options) {}
|
||||
|
||||
canPlayMediaType(mediaType) {
|
||||
return mediaType === 'Video';
|
||||
}
|
||||
|
||||
canQueueMediaType(mediaType) {
|
||||
return this.canPlayMediaType(mediaType);
|
||||
}
|
||||
|
||||
canPlayItem(item, playOptions) {
|
||||
return this._nativePlayer.isEnabled() &&
|
||||
playOptions.fullscreen &&
|
||||
!this.playbackManager.syncPlayEnabled;
|
||||
}
|
||||
|
||||
async stop(destroyPlayer) {
|
||||
this._nativePlayer.stopPlayer();
|
||||
|
||||
if (destroyPlayer) {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
nextTrack() {}
|
||||
|
||||
previousTrack() {}
|
||||
|
||||
seek(ticks) {
|
||||
this._nativePlayer.seek(ticks);
|
||||
}
|
||||
|
||||
currentTime(ms) {
|
||||
if (ms !== undefined) {
|
||||
this._nativePlayer.seekMs(ms);
|
||||
}
|
||||
return this._currentTime;
|
||||
}
|
||||
|
||||
duration(val) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or set volume percentage as as string
|
||||
*/
|
||||
volume(volume) {
|
||||
if (volume !== undefined) {
|
||||
this.setVolume(volume);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getVolume() {}
|
||||
|
||||
setVolume(vol) {
|
||||
let volume = parseInt(vol);
|
||||
this._nativePlayer.setVolume(volume);
|
||||
}
|
||||
|
||||
volumeUp() {}
|
||||
|
||||
volumeDown() {}
|
||||
|
||||
isMuted() {
|
||||
return false;
|
||||
}
|
||||
|
||||
setMute(mute) {
|
||||
// Assume 30% as default when unmuting
|
||||
this._nativePlayer.setVolume(mute ? 0 : 30);
|
||||
}
|
||||
|
||||
toggleMute() {}
|
||||
|
||||
paused() {
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
pause() {
|
||||
this._paused = true;
|
||||
this._nativePlayer.pausePlayer();
|
||||
}
|
||||
|
||||
unpause() {
|
||||
this._paused = false;
|
||||
this._nativePlayer.resumePlayer();
|
||||
}
|
||||
|
||||
playPause() {
|
||||
if (this._paused) {
|
||||
this.unpause();
|
||||
} else {
|
||||
this.pause();
|
||||
}
|
||||
}
|
||||
|
||||
canSetAudioStreamIndex() {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAudioStreamIndex(index) {}
|
||||
|
||||
setSubtitleStreamIndex(index) {}
|
||||
|
||||
async changeAudioStream(index) {}
|
||||
|
||||
async changeSubtitleStream(index) {}
|
||||
|
||||
getPlaylist() {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getCurrentPlaylistItemId() {}
|
||||
|
||||
setCurrentPlaylistItem() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
removeFromPlaylist() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._nativePlayer.destroyPlayer();
|
||||
}
|
||||
|
||||
async getDeviceProfile() {
|
||||
return {
|
||||
Name: 'ExoPlayer Stub',
|
||||
MaxStreamingBitrate: 100000000,
|
||||
MaxStaticBitrate: 100000000,
|
||||
MusicStreamingTranscodingBitrate: 320000,
|
||||
DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}],
|
||||
CodecProfiles: [],
|
||||
SubtitleProfiles: [],
|
||||
TranscodingProfiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
152
app/src/main/assets/native/ExternalPlayerPlugin.js
Normal file
152
app/src/main/assets/native/ExternalPlayerPlugin.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
export class ExternalPlayerPlugin {
|
||||
constructor({ events, playbackManager }) {
|
||||
window['ExtPlayer'] = this;
|
||||
|
||||
this.events = events;
|
||||
this.playbackManager = playbackManager;
|
||||
|
||||
this.name = 'External Player';
|
||||
this.type = 'mediaplayer';
|
||||
this.id = 'externalplayer';
|
||||
this.subtitleStreamIndex = -1;
|
||||
this.audioStreamIndex = -1;
|
||||
this.cachedDeviceProfile = null;
|
||||
|
||||
// Prioritize first
|
||||
this.priority = -2;
|
||||
this.supportsProgress = false;
|
||||
this.isLocalPlayer = true;
|
||||
|
||||
// Disable orientation lock
|
||||
this.isExternalPlayer = true;
|
||||
// _currentTime is in milliseconds
|
||||
this._currentTime = 0;
|
||||
this._paused = true;
|
||||
this._volume = 100;
|
||||
this._currentSrc = null;
|
||||
this._isIntro = false;
|
||||
|
||||
this._externalPlayer = window['ExternalPlayer'];
|
||||
}
|
||||
|
||||
canPlayMediaType(mediaType) {
|
||||
return mediaType === 'Video';
|
||||
}
|
||||
|
||||
canPlayItem(item, playOptions) {
|
||||
return this._externalPlayer.isEnabled() &&
|
||||
playOptions.fullscreen &&
|
||||
!this.playbackManager.syncPlayEnabled;
|
||||
}
|
||||
|
||||
currentSrc() {
|
||||
return this._currentSrc;
|
||||
}
|
||||
|
||||
async play(options) {
|
||||
this._currentTime = options.playerStartPositionTicks / 10000 || 0;
|
||||
this._paused = false;
|
||||
this._currentSrc = options.url;
|
||||
this._isIntro = options.item && options.item.ProviderIds && options.item.ProviderIds.hasOwnProperty("prerolls.video");
|
||||
const playOptions = options.item.playOptions;
|
||||
playOptions.ids = options.item ? [options.item.Id] : [];
|
||||
this._externalPlayer.initPlayer(JSON.stringify(playOptions));
|
||||
}
|
||||
|
||||
setSubtitleStreamIndex(index) { }
|
||||
|
||||
canSetAudioStreamIndex() {
|
||||
return false;
|
||||
}
|
||||
|
||||
setAudioStreamIndex(index) {
|
||||
}
|
||||
|
||||
duration(val) {
|
||||
return null;
|
||||
}
|
||||
|
||||
destroy() { }
|
||||
|
||||
pause() { }
|
||||
|
||||
unpause() { }
|
||||
|
||||
paused() {
|
||||
return this._paused;
|
||||
}
|
||||
|
||||
async stop(destroyPlayer) {
|
||||
if (destroyPlayer) {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
volume(val) {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
setMute(mute) {
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
return this._volume == 0;
|
||||
}
|
||||
|
||||
async notifyEnded() {
|
||||
let stopInfo = {
|
||||
src: this._currentSrc
|
||||
};
|
||||
|
||||
this.playbackManager._playNextAfterEnded = this._isIntro;
|
||||
this.events.trigger(this, 'stopped', [stopInfo]);
|
||||
this._currentSrc = this._currentTime = null;
|
||||
}
|
||||
|
||||
async notifyTimeUpdate(currentTime) {
|
||||
// Use duration (as if playback completed) if no time is provided
|
||||
currentTime = currentTime || this.playbackManager.duration(this) / 10000;
|
||||
this._timeUpdated = this._currentTime != currentTime;
|
||||
this._currentTime = currentTime;
|
||||
this.events.trigger(this, 'timeupdate');
|
||||
}
|
||||
|
||||
notifyCanceled() {
|
||||
// required to not mark an item as seen / completed without time changes
|
||||
let currentTime = this._currentTime || 0;
|
||||
this.notifyTimeUpdate(currentTime - 1);
|
||||
if (currentTime > 0) {
|
||||
this.notifyTimeUpdate(currentTime);
|
||||
}
|
||||
this.notifyEnded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently known player time in milliseconds
|
||||
*/
|
||||
currentTime() {
|
||||
return this._currentTime || 0;
|
||||
}
|
||||
|
||||
async changeSubtitleStream(index) {
|
||||
var innerIndex = Number(index);
|
||||
this.subtitleStreamIndex = innerIndex;
|
||||
}
|
||||
|
||||
async changeAudioStream(index) {
|
||||
var innerIndex = Number(index);
|
||||
this.audioStreamIndex = innerIndex;
|
||||
}
|
||||
|
||||
async getDeviceProfile() {
|
||||
return {
|
||||
Name: 'Android External Player Stub',
|
||||
MaxStreamingBitrate: 1_000_000_000,
|
||||
MaxStaticBitrate: 1_000_000_000,
|
||||
DirectPlayProfiles: [{Type: 'Video'}, {Type: 'Audio'}],
|
||||
CodecProfiles: [],
|
||||
SubtitleProfiles: [{Method: 'Embed'}, {Method: 'External'}, {Method: 'Drop'}],
|
||||
TranscodingProfiles: []
|
||||
};
|
||||
}
|
||||
}
|
||||
16
app/src/main/assets/native/NavigationPlugin.js
Normal file
16
app/src/main/assets/native/NavigationPlugin.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export class NavigationPlugin {
|
||||
constructor({ playbackManager }) {
|
||||
window['NavigationHelper'] = this;
|
||||
|
||||
this.playbackManager = playbackManager;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
var appRouter = window['Emby']['Page'];
|
||||
if (appRouter.canGoBack()) {
|
||||
appRouter.back();
|
||||
} else {
|
||||
window['NativeInterface'].exitApp();
|
||||
}
|
||||
}
|
||||
}
|
||||
1443
app/src/main/assets/native/chrome.cast.js
Normal file
1443
app/src/main/assets/native/chrome.cast.js
Normal file
File diff suppressed because it is too large
Load diff
15
app/src/main/assets/native/injectionScript.js
Normal file
15
app/src/main/assets/native/injectionScript.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
(() => {
|
||||
const scripts = [
|
||||
'/native/nativeshell.js',
|
||||
'/native/EventEmitter.js',
|
||||
document.currentScript.src.concat('?deferred=true&ts=', Date.now())
|
||||
];
|
||||
for (const script of scripts) {
|
||||
const scriptElement = document.createElement('script');
|
||||
scriptElement.src = script;
|
||||
scriptElement.charset = 'utf-8';
|
||||
scriptElement.setAttribute('defer', '');
|
||||
document.body.appendChild(scriptElement);
|
||||
}
|
||||
document.currentScript.remove();
|
||||
})();
|
||||
189
app/src/main/assets/native/nativeshell.js
Normal file
189
app/src/main/assets/native/nativeshell.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
const features = [
|
||||
"filedownload",
|
||||
"displaylanguage",
|
||||
"subtitleappearancesettings",
|
||||
"subtitleburnsettings",
|
||||
//'sharing',
|
||||
"exit",
|
||||
"htmlaudioautoplay",
|
||||
"htmlvideoautoplay",
|
||||
"externallinks",
|
||||
"clientsettings",
|
||||
"multiserver",
|
||||
"physicalvolumecontrol",
|
||||
"remotecontrol",
|
||||
"castmenuhashchange"
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
'NavigationPlugin',
|
||||
'ExoPlayerPlugin',
|
||||
'ExternalPlayerPlugin'
|
||||
];
|
||||
|
||||
// Add plugin loaders
|
||||
for (const plugin of plugins) {
|
||||
window[plugin] = async () => {
|
||||
const pluginDefinition = await import(`/native/${plugin}.js`);
|
||||
return pluginDefinition[plugin];
|
||||
};
|
||||
}
|
||||
|
||||
let deviceId;
|
||||
let deviceName;
|
||||
let appName;
|
||||
let appVersion;
|
||||
|
||||
window.NativeShell = {
|
||||
enableFullscreen() {
|
||||
window.NativeInterface.enableFullscreen();
|
||||
},
|
||||
|
||||
disableFullscreen() {
|
||||
window.NativeInterface.disableFullscreen();
|
||||
},
|
||||
|
||||
openUrl(url, target) {
|
||||
window.NativeInterface.openUrl(url);
|
||||
},
|
||||
|
||||
updateMediaSession(mediaInfo) {
|
||||
window.NativeInterface.updateMediaSession(JSON.stringify(mediaInfo));
|
||||
},
|
||||
|
||||
hideMediaSession() {
|
||||
window.NativeInterface.hideMediaSession();
|
||||
},
|
||||
|
||||
updateVolumeLevel(value) {
|
||||
window.NativeInterface.updateVolumeLevel(value);
|
||||
},
|
||||
|
||||
downloadFile(downloadInfo) {
|
||||
window.NativeInterface.downloadFiles(JSON.stringify([downloadInfo]));
|
||||
},
|
||||
|
||||
downloadFiles(downloadInfo) {
|
||||
window.NativeInterface.downloadFiles(JSON.stringify(downloadInfo));
|
||||
},
|
||||
|
||||
openClientSettings() {
|
||||
window.NativeInterface.openClientSettings();
|
||||
},
|
||||
|
||||
selectServer() {
|
||||
window.NativeInterface.openServerSelection();
|
||||
},
|
||||
|
||||
getPlugins() {
|
||||
return plugins;
|
||||
},
|
||||
|
||||
async execCast(action, args, callback) {
|
||||
this.castCallbacks = this.castCallbacks || {};
|
||||
this.castCallbacks[action] = callback;
|
||||
window.NativeInterface.execCast(action, JSON.stringify(args));
|
||||
},
|
||||
|
||||
async castCallback(action, keep, err, result) {
|
||||
const callbacks = this.castCallbacks || {};
|
||||
const callback = callbacks[action];
|
||||
callback && callback(err || null, result);
|
||||
if (!keep) {
|
||||
delete callbacks[action];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function getDeviceProfile(profileBuilder, item) {
|
||||
const profile = profileBuilder({
|
||||
enableMkvProgressive: false
|
||||
});
|
||||
|
||||
profile.CodecProfiles = profile.CodecProfiles.filter(function (i) {
|
||||
return i.Type === "Audio";
|
||||
});
|
||||
|
||||
profile.CodecProfiles.push({
|
||||
Type: "Video",
|
||||
Container: "avi",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "NotEquals",
|
||||
Property: "VideoCodecTag",
|
||||
Value: "xvid"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
profile.CodecProfiles.push({
|
||||
Type: "Video",
|
||||
Codec: "h264",
|
||||
Conditions: [
|
||||
{
|
||||
Condition: "EqualsAny",
|
||||
Property: "VideoProfile",
|
||||
Value: "high|main|baseline|constrained baseline"
|
||||
},
|
||||
{
|
||||
Condition: "LessThanEqual",
|
||||
Property: "VideoLevel",
|
||||
Value: "41"
|
||||
}]
|
||||
});
|
||||
|
||||
profile.TranscodingProfiles.reduce(function (profiles, p) {
|
||||
if (p.Type === "Video" && p.CopyTimestamps === true && p.VideoCodec === "h264") {
|
||||
p.AudioCodec += ",ac3";
|
||||
profiles.push(p);
|
||||
}
|
||||
return profiles;
|
||||
}, []);
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
window.NativeShell.AppHost = {
|
||||
init() {
|
||||
try {
|
||||
const result = JSON.parse(window.NativeInterface.getDeviceInformation());
|
||||
// set globally so they can be used elsewhere
|
||||
deviceId = result.deviceId;
|
||||
deviceName = result.deviceName;
|
||||
appName = result.appName;
|
||||
appVersion = result.appVersion;
|
||||
|
||||
return Promise.resolve({
|
||||
deviceId,
|
||||
deviceName,
|
||||
appName,
|
||||
appVersion,
|
||||
});
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
},
|
||||
getDefaultLayout() {
|
||||
return "mobile";
|
||||
},
|
||||
supports(command) {
|
||||
return features.includes(command.toLowerCase());
|
||||
},
|
||||
getDeviceProfile,
|
||||
getSyncProfile: getDeviceProfile,
|
||||
deviceName() {
|
||||
return deviceName;
|
||||
},
|
||||
deviceId() {
|
||||
return deviceId;
|
||||
},
|
||||
appName() {
|
||||
return appName;
|
||||
},
|
||||
appVersion() {
|
||||
return appVersion;
|
||||
},
|
||||
exit() {
|
||||
window.NativeInterface.exitApp();
|
||||
}
|
||||
};
|
||||
41
app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt
Normal file
41
app/src/main/java/org/jellyfin/mobile/JellyfinApplication.kt
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package org.jellyfin.mobile
|
||||
|
||||
import android.app.Application
|
||||
import android.webkit.WebView
|
||||
import org.jellyfin.mobile.app.apiModule
|
||||
import org.jellyfin.mobile.app.applicationModule
|
||||
import org.jellyfin.mobile.data.databaseModule
|
||||
import org.jellyfin.mobile.utils.JellyTree
|
||||
import org.jellyfin.mobile.utils.isWebViewSupported
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.androidx.fragment.koin.fragmentFactory
|
||||
import org.koin.core.context.startKoin
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("unused")
|
||||
class JellyfinApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Setup logging
|
||||
Timber.plant(JellyTree())
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
// Enable WebView debugging
|
||||
if (isWebViewSupported()) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
startKoin {
|
||||
androidContext(this@JellyfinApplication)
|
||||
fragmentFactory()
|
||||
|
||||
modules(
|
||||
applicationModule,
|
||||
apiModule,
|
||||
databaseModule,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Normal file
207
app/src/main/java/org/jellyfin/mobile/MainActivity.kt
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
package org.jellyfin.mobile
|
||||
|
||||
import android.app.Service
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.view.OrientationEventListener
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.addCallback
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStarted
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
import org.jellyfin.mobile.player.cast.Chromecast
|
||||
import org.jellyfin.mobile.player.cast.IChromecast
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.setup.ConnectFragment
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.BackPressInterceptor
|
||||
import org.jellyfin.mobile.utils.BluetoothPermissionHelper
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.PermissionRequestHelper
|
||||
import org.jellyfin.mobile.utils.SmartOrientationListener
|
||||
import org.jellyfin.mobile.utils.extensions.replaceFragment
|
||||
import org.jellyfin.mobile.utils.isWebViewSupported
|
||||
import org.jellyfin.mobile.webapp.RemotePlayerService
|
||||
import org.jellyfin.mobile.webapp.WebViewFragment
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.koin.androidx.fragment.android.setupKoinFragmentFactory
|
||||
import org.koin.androidx.viewmodel.ext.android.viewModel
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private val activityEventHandler: ActivityEventHandler = get()
|
||||
val mainViewModel: MainViewModel by viewModel()
|
||||
val bluetoothPermissionHelper: BluetoothPermissionHelper = BluetoothPermissionHelper(this, get())
|
||||
val chromecast: IChromecast = Chromecast()
|
||||
private val permissionRequestHelper: PermissionRequestHelper by inject()
|
||||
|
||||
var serviceBinder: RemotePlayerService.ServiceBinder? = null
|
||||
private set
|
||||
private val serviceConnection: ServiceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(componentName: ComponentName, binder: IBinder) {
|
||||
serviceBinder = binder as? RemotePlayerService.ServiceBinder
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(componentName: ComponentName) {
|
||||
serviceBinder = null
|
||||
}
|
||||
}
|
||||
|
||||
private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(this) }
|
||||
|
||||
/**
|
||||
* Passes back press events onto the currently visible [Fragment] if it implements the [BackPressInterceptor] interface.
|
||||
*
|
||||
* If the current fragment does not implement [BackPressInterceptor] or has decided not to intercept the event
|
||||
* (see result of [BackPressInterceptor.onInterceptBackPressed]), the topmost backstack entry will be popped.
|
||||
*
|
||||
* If there is no topmost backstack entry, the event will be passed onto the dispatcher's fallback handler.
|
||||
*/
|
||||
private val onBackPressedCallback: OnBackPressedCallback.() -> Unit = callback@{
|
||||
val currentFragment = supportFragmentManager.findFragmentById(R.id.fragment_container)
|
||||
if (currentFragment is BackPressInterceptor && currentFragment.onInterceptBackPressed()) {
|
||||
// Top fragment handled back press
|
||||
return@callback
|
||||
}
|
||||
|
||||
// This is the same default action as in Activity.onBackPressed
|
||||
if (!supportFragmentManager.isStateSaved && supportFragmentManager.popBackStackImmediate()) {
|
||||
// Removed fragment from back stack
|
||||
return@callback
|
||||
}
|
||||
|
||||
// Let the system handle the back press
|
||||
isEnabled = false
|
||||
// Make sure that we *really* call the fallback handler
|
||||
assert(!onBackPressedDispatcher.hasEnabledCallbacks()) {
|
||||
"MainActivity should be the lowest onBackPressCallback"
|
||||
}
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
isEnabled = true // re-enable callback in case activity isn't finished
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
setupKoinFragmentFactory()
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
|
||||
// Check WebView support
|
||||
if (!isWebViewSupported()) {
|
||||
AlertDialog.Builder(this).apply {
|
||||
setTitle(R.string.dialog_web_view_not_supported)
|
||||
setMessage(R.string.dialog_web_view_not_supported_message)
|
||||
setCancelable(false)
|
||||
if (AndroidVersion.isAtLeastN) {
|
||||
setNeutralButton(R.string.dialog_button_open_settings) { _, _ ->
|
||||
startActivity(Intent(Settings.ACTION_WEBVIEW_SETTINGS))
|
||||
Toast.makeText(context, R.string.toast_reopen_after_change, Toast.LENGTH_LONG).show()
|
||||
finishAfterTransition()
|
||||
}
|
||||
}
|
||||
setNegativeButton(R.string.dialog_button_close_app) { _, _ ->
|
||||
finishAfterTransition()
|
||||
}
|
||||
}.show()
|
||||
return
|
||||
}
|
||||
|
||||
// Bind player service
|
||||
bindService(Intent(this, RemotePlayerService::class.java), serviceConnection, Service.BIND_AUTO_CREATE)
|
||||
|
||||
// Subscribe to activity events
|
||||
with(activityEventHandler) { subscribe() }
|
||||
|
||||
// Load UI
|
||||
lifecycleScope.launch {
|
||||
mainViewModel.serverState.collectLatest { state ->
|
||||
lifecycle.withStarted {
|
||||
handleServerState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle back presses
|
||||
onBackPressedDispatcher.addCallback(this, onBackPressed = onBackPressedCallback)
|
||||
|
||||
// Setup Chromecast
|
||||
chromecast.initializePlugin(this)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
orientationListener.enable()
|
||||
}
|
||||
|
||||
private fun handleServerState(state: ServerState) {
|
||||
with(supportFragmentManager) {
|
||||
val currentFragment = findFragmentById(R.id.fragment_container)
|
||||
when (state) {
|
||||
ServerState.Pending -> {
|
||||
// TODO add loading indicator
|
||||
}
|
||||
is ServerState.Unset -> {
|
||||
if (currentFragment !is ConnectFragment) {
|
||||
replaceFragment<ConnectFragment>()
|
||||
}
|
||||
}
|
||||
is ServerState.Available -> {
|
||||
if (currentFragment !is WebViewFragment || currentFragment.server != state.server) {
|
||||
replaceFragment<WebViewFragment>(
|
||||
Bundle().apply {
|
||||
putParcelable(Constants.FRAGMENT_WEB_VIEW_EXTRA_SERVER, state.server)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
|
||||
permissionRequestHelper.handleRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
override fun onSupportNavigateUp(): Boolean {
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onUserLeaveHint() {
|
||||
for (fragment in supportFragmentManager.fragments) {
|
||||
if (fragment is PlayerFragment && fragment.isVisible) {
|
||||
fragment.onUserLeaveHint()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
orientationListener.disable()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unbindService(serviceConnection)
|
||||
chromecast.destroy()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
49
app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Normal file
49
app/src/main/java/org/jellyfin/mobile/MainViewModel.kt
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
package org.jellyfin.mobile
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.app.ApiClientController
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity
|
||||
|
||||
class MainViewModel(
|
||||
app: Application,
|
||||
private val apiClientController: ApiClientController,
|
||||
) : AndroidViewModel(app) {
|
||||
private val _serverState: MutableStateFlow<ServerState> = MutableStateFlow(ServerState.Pending)
|
||||
val serverState: StateFlow<ServerState> get() = _serverState
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
refreshServer()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun switchServer(hostname: String) {
|
||||
apiClientController.setupServer(hostname)
|
||||
refreshServer()
|
||||
}
|
||||
|
||||
private suspend fun refreshServer() {
|
||||
val serverEntity = apiClientController.loadSavedServer()
|
||||
_serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset
|
||||
}
|
||||
|
||||
/**
|
||||
* Temporarily unset the selected server to be able to connect to a different one
|
||||
*/
|
||||
fun resetServer() {
|
||||
_serverState.value = ServerState.Unset
|
||||
}
|
||||
}
|
||||
|
||||
sealed class ServerState {
|
||||
open val server: ServerEntity? = null
|
||||
|
||||
object Pending : ServerState()
|
||||
object Unset : ServerState()
|
||||
class Available(override val server: ServerEntity) : ServerState()
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.jellyfin.mobile.app
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.mobile.data.dao.ServerDao
|
||||
import org.jellyfin.mobile.data.dao.UserDao
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
|
||||
class ApiClientController(
|
||||
private val appPreferences: AppPreferences,
|
||||
private val jellyfin: Jellyfin,
|
||||
private val apiClient: ApiClient,
|
||||
private val serverDao: ServerDao,
|
||||
private val userDao: UserDao,
|
||||
) {
|
||||
private val baseDeviceInfo: DeviceInfo
|
||||
get() = jellyfin.options.deviceInfo!!
|
||||
|
||||
/**
|
||||
* Store server with [hostname] in the database.
|
||||
*/
|
||||
suspend fun setupServer(hostname: String) {
|
||||
appPreferences.currentServerId = withContext(Dispatchers.IO) {
|
||||
serverDao.getServerByHostname(hostname)?.id ?: serverDao.insert(hostname)
|
||||
}
|
||||
apiClient.update(baseUrl = hostname)
|
||||
}
|
||||
|
||||
suspend fun setupUser(serverId: Long, userId: String, accessToken: String) {
|
||||
appPreferences.currentUserId = withContext(Dispatchers.IO) {
|
||||
userDao.upsert(serverId, userId, accessToken)
|
||||
}
|
||||
configureApiClientUser(userId, accessToken)
|
||||
}
|
||||
|
||||
suspend fun loadSavedServer(): ServerEntity? {
|
||||
val server = withContext(Dispatchers.IO) {
|
||||
val serverId = appPreferences.currentServerId ?: return@withContext null
|
||||
serverDao.getServer(serverId)
|
||||
}
|
||||
configureApiClientServer(server)
|
||||
return server
|
||||
}
|
||||
|
||||
suspend fun loadSavedServerUser() {
|
||||
val serverUser = withContext(Dispatchers.IO) {
|
||||
val serverId = appPreferences.currentServerId ?: return@withContext null
|
||||
val userId = appPreferences.currentUserId ?: return@withContext null
|
||||
userDao.getServerUser(serverId, userId)
|
||||
}
|
||||
|
||||
configureApiClientServer(serverUser?.server)
|
||||
|
||||
if (serverUser?.user?.accessToken != null) {
|
||||
configureApiClientUser(serverUser.user.userId, serverUser.user.accessToken)
|
||||
} else {
|
||||
resetApiClientUser()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadPreviouslyUsedServers(): List<ServerEntity> = withContext(Dispatchers.IO) {
|
||||
serverDao.getAllServers().filterNot { server ->
|
||||
server.id == appPreferences.currentServerId
|
||||
}
|
||||
}
|
||||
|
||||
private fun configureApiClientServer(server: ServerEntity?) {
|
||||
apiClient.update(baseUrl = server?.hostname)
|
||||
}
|
||||
|
||||
private fun configureApiClientUser(userId: String, accessToken: String) {
|
||||
apiClient.update(
|
||||
accessToken = accessToken,
|
||||
// Append user id to device id to ensure uniqueness across sessions
|
||||
deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + userId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resetApiClientUser() {
|
||||
apiClient.update(
|
||||
accessToken = null,
|
||||
deviceInfo = baseDeviceInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt
Normal file
19
app/src/main/java/org/jellyfin/mobile/app/ApiModule.kt
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package org.jellyfin.mobile.app
|
||||
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.module
|
||||
|
||||
val apiModule = module {
|
||||
// Jellyfin API builder and API client instance
|
||||
single {
|
||||
createJellyfin {
|
||||
context = androidContext()
|
||||
clientInfo = ClientInfo(name = Constants.APP_INFO_NAME, version = Constants.APP_INFO_VERSION)
|
||||
}
|
||||
}
|
||||
single { get<Jellyfin>().createApi() }
|
||||
}
|
||||
153
app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Normal file
153
app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
package org.jellyfin.mobile.app
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import coil.ImageLoader
|
||||
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
|
||||
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
|
||||
import com.google.android.exoplayer2.extractor.ts.TsExtractor
|
||||
import com.google.android.exoplayer2.source.DefaultMediaSourceFactory
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.SingleSampleMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import com.google.android.exoplayer2.upstream.DataSource
|
||||
import com.google.android.exoplayer2.upstream.DataSpec
|
||||
import com.google.android.exoplayer2.upstream.DefaultDataSource
|
||||
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.ResolvingDataSource
|
||||
import com.google.android.exoplayer2.util.Util
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import okhttp3.OkHttpClient
|
||||
import org.chromium.net.CronetEngine
|
||||
import org.chromium.net.CronetProvider
|
||||
import org.jellyfin.mobile.MainViewModel
|
||||
import org.jellyfin.mobile.bridge.NativePlayer
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
|
||||
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
|
||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.source.MediaSourceResolver
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.setup.ConnectionHelper
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.PermissionRequestHelper
|
||||
import org.jellyfin.mobile.utils.isLowRamDevice
|
||||
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
|
||||
import org.jellyfin.mobile.webapp.WebViewFragment
|
||||
import org.jellyfin.mobile.webapp.WebappFunctionChannel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.androidx.fragment.dsl.fragment
|
||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
|
||||
private const val HTTP_CACHE_SIZE: Long = 16 * 1024 * 1024
|
||||
private const val TS_SEARCH_PACKETS = 1800
|
||||
|
||||
val applicationModule = module {
|
||||
single { AppPreferences(androidApplication()) }
|
||||
single { OkHttpClient() }
|
||||
single { ImageLoader(androidApplication()) }
|
||||
single { PermissionRequestHelper() }
|
||||
single { RemoteVolumeProvider(get()) }
|
||||
single(named(PLAYER_EVENT_CHANNEL)) { Channel<PlayerEvent>() }
|
||||
|
||||
// Controllers
|
||||
single { ApiClientController(get(), get(), get(), get(), get()) }
|
||||
|
||||
// Event handlers and channels
|
||||
single { ActivityEventHandler(get()) }
|
||||
single { WebappFunctionChannel() }
|
||||
|
||||
// Bridge interfaces
|
||||
single { NativePlayer(get(), get(), get(named(PLAYER_EVENT_CHANNEL))) }
|
||||
|
||||
// ViewModels
|
||||
viewModel { MainViewModel(get(), get()) }
|
||||
|
||||
// Fragments
|
||||
fragment { WebViewFragment() }
|
||||
fragment { PlayerFragment() }
|
||||
|
||||
// Connection helper
|
||||
single { ConnectionHelper(get(), get()) }
|
||||
|
||||
// Media player helpers
|
||||
single { MediaSourceResolver(get()) }
|
||||
single { DeviceProfileBuilder(get()) }
|
||||
single { QualityOptionsProvider() }
|
||||
|
||||
// ExoPlayer factories
|
||||
single<DataSource.Factory> {
|
||||
val context: Context = get()
|
||||
val apiClient: ApiClient = get()
|
||||
|
||||
val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
|
||||
(provider.name == CronetProvider.PROVIDER_NAME_APP_PACKAGED) && provider.isEnabled
|
||||
}
|
||||
|
||||
val baseDataSourceFactory = if (provider != null) {
|
||||
val cronetEngine = provider.createBuilder()
|
||||
.enableHttp2(true)
|
||||
.enableQuic(true)
|
||||
.enableBrotli(true)
|
||||
.enableHttpCache(CronetEngine.Builder.HTTP_CACHE_IN_MEMORY, HTTP_CACHE_SIZE)
|
||||
.build()
|
||||
CronetDataSource.Factory(cronetEngine, Executors.newCachedThreadPool()).apply {
|
||||
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
|
||||
}
|
||||
} else {
|
||||
DefaultHttpDataSource.Factory().apply {
|
||||
setUserAgent(Util.getUserAgent(context, Constants.APP_INFO_NAME))
|
||||
}
|
||||
}
|
||||
|
||||
val dataSourceFactory = DefaultDataSource.Factory(context, baseDataSourceFactory)
|
||||
|
||||
// Add authorization header. This is needed as we don't pass the
|
||||
// access token in the URL for Android Auto.
|
||||
ResolvingDataSource.Factory(dataSourceFactory) { dataSpec: DataSpec ->
|
||||
// Only send authorization header if URI matches the jellyfin server
|
||||
val baseUrlAuthority = apiClient.baseUrl?.toUri()?.authority
|
||||
|
||||
if (dataSpec.uri.authority == baseUrlAuthority) {
|
||||
val authorizationHeaderString = AuthorizationHeaderBuilder.buildHeader(
|
||||
clientName = apiClient.clientInfo.name,
|
||||
clientVersion = apiClient.clientInfo.version,
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
deviceName = apiClient.deviceInfo.name,
|
||||
accessToken = apiClient.accessToken,
|
||||
)
|
||||
|
||||
dataSpec.withRequestHeaders(hashMapOf("Authorization" to authorizationHeaderString))
|
||||
} else {
|
||||
dataSpec
|
||||
}
|
||||
}
|
||||
}
|
||||
single<MediaSource.Factory> {
|
||||
val context: Context = get()
|
||||
val extractorsFactory = DefaultExtractorsFactory().apply {
|
||||
// https://github.com/google/ExoPlayer/issues/8571
|
||||
setTsExtractorTimestampSearchBytes(
|
||||
when {
|
||||
!context.isLowRamDevice -> TS_SEARCH_PACKETS * TsExtractor.TS_PACKET_SIZE // 3x default
|
||||
else -> TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES
|
||||
},
|
||||
)
|
||||
}
|
||||
DefaultMediaSourceFactory(get<DataSource.Factory>(), extractorsFactory)
|
||||
}
|
||||
single { ProgressiveMediaSource.Factory(get()) }
|
||||
single { HlsMediaSource.Factory(get<DataSource.Factory>()) }
|
||||
single { SingleSampleMediaSource.Factory(get()) }
|
||||
|
||||
// Media components
|
||||
single { LibraryBrowser(get(), get()) }
|
||||
}
|
||||
127
app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Normal file
127
app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package org.jellyfin.mobile.app
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Environment
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
import androidx.core.content.edit
|
||||
import org.jellyfin.mobile.settings.ExternalPlayerPackage
|
||||
import org.jellyfin.mobile.settings.VideoPlayerType
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import java.io.File
|
||||
|
||||
class AppPreferences(context: Context) {
|
||||
private val sharedPreferences: SharedPreferences =
|
||||
context.getSharedPreferences("${context.packageName}_preferences", Context.MODE_PRIVATE)
|
||||
|
||||
var currentServerId: Long?
|
||||
get() = sharedPreferences.getLong(Constants.PREF_SERVER_ID, -1).takeIf { it >= 0 }
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
if (value != null) putLong(Constants.PREF_SERVER_ID, value) else remove(Constants.PREF_SERVER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
var currentUserId: Long?
|
||||
get() = sharedPreferences.getLong(Constants.PREF_USER_ID, -1).takeIf { it >= 0 }
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
if (value != null) putLong(Constants.PREF_USER_ID, value) else remove(Constants.PREF_USER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
var ignoreBatteryOptimizations: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, false)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Constants.PREF_IGNORE_BATTERY_OPTIMIZATIONS, value)
|
||||
}
|
||||
}
|
||||
|
||||
var ignoreWebViewChecks: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, false)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Constants.PREF_IGNORE_WEBVIEW_CHECKS, value)
|
||||
}
|
||||
}
|
||||
|
||||
var ignoreBluetoothPermission: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, false)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putBoolean(Constants.PREF_IGNORE_BLUETOOTH_PERMISSION, value)
|
||||
}
|
||||
}
|
||||
|
||||
var downloadMethod: Int?
|
||||
get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 }
|
||||
set(value) {
|
||||
if (value != null) {
|
||||
sharedPreferences.edit {
|
||||
putInt(Constants.PREF_DOWNLOAD_METHOD, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var downloadLocation: String
|
||||
get() {
|
||||
val savedStorage = sharedPreferences.getString(Constants.PREF_DOWNLOAD_LOCATION, null)
|
||||
if (savedStorage != null) {
|
||||
if (File(savedStorage).parentFile?.isDirectory == true) {
|
||||
// Saved location is still valid
|
||||
return savedStorage
|
||||
} else {
|
||||
// Reset download option if corrupt
|
||||
sharedPreferences.edit {
|
||||
remove(Constants.PREF_DOWNLOAD_LOCATION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return default storage location
|
||||
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
|
||||
}
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
if (File(value).parentFile?.isDirectory == true) {
|
||||
putString(Constants.PREF_DOWNLOAD_LOCATION, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val musicNotificationAlwaysDismissible: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE, false)
|
||||
|
||||
@VideoPlayerType
|
||||
val videoPlayerType: String
|
||||
get() = sharedPreferences.getString(Constants.PREF_VIDEO_PLAYER_TYPE, VideoPlayerType.WEB_PLAYER)!!
|
||||
|
||||
val exoPlayerStartLandscapeVideoInLandscape: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE, false)
|
||||
|
||||
val exoPlayerAllowSwipeGestures: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES, true)
|
||||
|
||||
val exoPlayerRememberBrightness: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS, false)
|
||||
|
||||
var exoPlayerBrightness: Float
|
||||
get() = sharedPreferences.getFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, BRIGHTNESS_OVERRIDE_NONE)
|
||||
set(value) {
|
||||
sharedPreferences.edit {
|
||||
putFloat(Constants.PREF_EXOPLAYER_BRIGHTNESS, value)
|
||||
}
|
||||
}
|
||||
|
||||
val exoPlayerAllowBackgroundAudio: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO, false)
|
||||
|
||||
val exoPlayerDirectPlayAss: Boolean
|
||||
get() = sharedPreferences.getBoolean(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS, false)
|
||||
|
||||
@ExternalPlayerPackage
|
||||
var externalPlayerApp: String
|
||||
get() = sharedPreferences.getString(Constants.PREF_EXTERNAL_PLAYER_APP, ExternalPlayerPackage.SYSTEM_DEFAULT)!!
|
||||
set(value) = sharedPreferences.edit { putString(Constants.PREF_EXTERNAL_PLAYER_APP, value) }
|
||||
}
|
||||
317
app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt
Normal file
317
app/src/main/java/org/jellyfin/mobile/bridge/ExternalPlayer.kt
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
package org.jellyfin.mobile.bridge
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultRegistry
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.text.isDigitsOnly
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.player.PlayerException
|
||||
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.source.MediaSourceResolver
|
||||
import org.jellyfin.mobile.settings.ExternalPlayerPackage
|
||||
import org.jellyfin.mobile.settings.VideoPlayerType
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.isPackageInstalled
|
||||
import org.jellyfin.mobile.utils.toast
|
||||
import org.jellyfin.mobile.webapp.WebappFunctionChannel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.operations.VideosApi
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
class ExternalPlayer(
|
||||
private val context: Context,
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
registry: ActivityResultRegistry,
|
||||
) : KoinComponent {
|
||||
private val coroutinesScope = MainScope()
|
||||
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val webappFunctionChannel: WebappFunctionChannel by inject()
|
||||
private val mediaSourceResolver: MediaSourceResolver by inject()
|
||||
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
|
||||
private val externalPlayerProfile: DeviceProfile = deviceProfileBuilder.getExternalPlayerProfile()
|
||||
private val apiClient: ApiClient = get()
|
||||
private val videosApi: VideosApi = apiClient.videosApi
|
||||
|
||||
private val playerContract = registry.register(
|
||||
"externalplayer",
|
||||
lifecycleOwner,
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) { result ->
|
||||
val resultCode = result.resultCode
|
||||
val intent = result.data
|
||||
when (val action = intent?.action) {
|
||||
Constants.MPV_PLAYER_RESULT_ACTION -> handleMPVPlayer(resultCode, intent)
|
||||
Constants.MX_PLAYER_RESULT_ACTION -> handleMXPlayer(resultCode, intent)
|
||||
Constants.VLC_PLAYER_RESULT_ACTION -> handleVLCPlayer(resultCode, intent)
|
||||
else -> {
|
||||
if (action != null && resultCode != Activity.RESULT_CANCELED) {
|
||||
Timber.d("Unknown action $action [resultCode=$resultCode]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_not_supported_yet, Toast.LENGTH_LONG)
|
||||
} else {
|
||||
Timber.d("Playback canceled: no player selected or player without action result")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_invalid_player, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER
|
||||
|
||||
@JavascriptInterface
|
||||
fun initPlayer(args: String) {
|
||||
val playOptions = PlayOptions.fromJson(JSONObject(args))
|
||||
val itemId = playOptions?.run {
|
||||
ids.firstOrNull() ?: mediaSourceId?.toUUIDOrNull() // fallback if ids is empty
|
||||
}
|
||||
if (playOptions == null || itemId == null) {
|
||||
context.toast(R.string.player_error_invalid_play_options)
|
||||
return
|
||||
}
|
||||
|
||||
coroutinesScope.launch {
|
||||
// Resolve media source to query info about external (subtitle) streams
|
||||
mediaSourceResolver.resolveMediaSource(
|
||||
itemId = itemId,
|
||||
mediaSourceId = playOptions.mediaSourceId,
|
||||
deviceProfile = externalPlayerProfile,
|
||||
startTimeTicks = playOptions.startPositionTicks,
|
||||
audioStreamIndex = playOptions.audioStreamIndex,
|
||||
subtitleStreamIndex = playOptions.subtitleStreamIndex,
|
||||
maxStreamingBitrate = Int.MAX_VALUE, // ensure we always direct play
|
||||
autoOpenLiveStream = false,
|
||||
).onSuccess { jellyfinMediaSource ->
|
||||
playMediaSource(playOptions, jellyfinMediaSource)
|
||||
}.onFailure { error ->
|
||||
when (error as? PlayerException) {
|
||||
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
|
||||
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
|
||||
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
|
||||
null -> throw error // Unknown error, rethrow from here
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun playMediaSource(playOptions: PlayOptions, source: JellyfinMediaSource) {
|
||||
// Create direct play URL
|
||||
val url = videosApi.getVideoStreamUrl(
|
||||
itemId = source.itemId,
|
||||
static = true,
|
||||
mediaSourceId = source.id,
|
||||
playSessionId = source.playSessionId,
|
||||
)
|
||||
|
||||
// Select correct subtitle
|
||||
val selectedSubtitleStream = playOptions.subtitleStreamIndex?.let { index ->
|
||||
source.mediaStreams.getOrNull(index)
|
||||
}
|
||||
source.selectSubtitleStream(selectedSubtitleStream)
|
||||
|
||||
// Build playback intent
|
||||
val playerIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
if (context.packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
|
||||
component = getComponent(appPreferences.externalPlayerApp)
|
||||
}
|
||||
setDataAndType(Uri.parse(url), "video/*")
|
||||
putExtra("title", source.name)
|
||||
putExtra("position", source.startTimeMs.toInt())
|
||||
putExtra("return_result", true)
|
||||
putExtra("secure_uri", true)
|
||||
|
||||
val externalSubs = source.externalSubtitleStreams
|
||||
val enabledSubUrl = when {
|
||||
source.selectedSubtitleStream != null -> {
|
||||
externalSubs.find { stream -> stream.index == source.selectedSubtitleStream?.index }?.let { sub ->
|
||||
apiClient.createUrl(sub.deliveryUrl)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
// MX Player API / MPV
|
||||
val subtitleUris = externalSubs.map { stream ->
|
||||
Uri.parse(apiClient.createUrl(stream.deliveryUrl))
|
||||
}
|
||||
putExtra("subs", subtitleUris.toTypedArray())
|
||||
putExtra("subs.name", externalSubs.map(ExternalSubtitleStream::displayTitle).toTypedArray())
|
||||
putExtra("subs.filename", externalSubs.map(ExternalSubtitleStream::language).toTypedArray())
|
||||
putExtra("subs.enable", enabledSubUrl?.let { url -> arrayOf(Uri.parse(url)) } ?: emptyArray())
|
||||
|
||||
// VLC
|
||||
if (enabledSubUrl != null) putExtra("subtitles_location", enabledSubUrl)
|
||||
}
|
||||
playerContract.launch(playerIntent)
|
||||
Timber.d(
|
||||
"Starting playback [id=${source.itemId}, title=${source.name}, " +
|
||||
"playMethod=${source.playMethod}, startTimeMs=${source.startTimeMs}]",
|
||||
)
|
||||
}
|
||||
|
||||
private fun notifyEvent(event: String, parameters: String = "") {
|
||||
if (event in ALLOWED_EVENTS && parameters.isDigitsOnly()) {
|
||||
webappFunctionChannel.call("window.ExtPlayer.notify$event($parameters)")
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/mpv-android/mpv-android/commit/f70298fe23c4872ea04fe4f2a8b378b986460d98
|
||||
private fun handleMPVPlayer(resultCode: Int, data: Intent) {
|
||||
val player = "MPV Player"
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
val position = data.getIntExtra("position", 0)
|
||||
if (position > 0) {
|
||||
Timber.d("Playback stopped [player=$player, position=$position]")
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
} else {
|
||||
Timber.d("Playback completed [player=$player]")
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE)
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
}
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
Timber.d("Playback stopped by unknown error [player=$player]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
else -> {
|
||||
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://sites.google.com/site/mxvpen/api
|
||||
private fun handleMXPlayer(resultCode: Int, data: Intent) {
|
||||
val player = "MX Player"
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
when (val endBy = data.getStringExtra("end_by")) {
|
||||
"playback_completion" -> {
|
||||
Timber.d("Playback completed [player=$player]")
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE)
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
}
|
||||
"user" -> {
|
||||
val position = data.getIntExtra("position", 0)
|
||||
val duration = data.getIntExtra("duration", 0)
|
||||
if (position > 0) {
|
||||
Timber.d("Playback stopped [player=$player, position=$position, duration=$duration]")
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE, "$position")
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
} else {
|
||||
Timber.d("Invalid state [player=$player, position=$position, duration=$duration]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Timber.d("Invalid state [player=$player, endBy=$endBy]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
Timber.d("Playback stopped by user [player=$player]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
}
|
||||
Activity.RESULT_FIRST_USER -> {
|
||||
Timber.d("Playback stopped by unknown error [player=$player]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
else -> {
|
||||
Timber.d("Invalid state [player=$player, resultCode=$resultCode]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://wiki.videolan.org/Android_Player_Intents/
|
||||
private fun handleVLCPlayer(resultCode: Int, data: Intent) {
|
||||
val player = "VLC Player"
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
val extraPosition = data.getLongExtra("extra_position", 0L)
|
||||
val extraDuration = data.getLongExtra("extra_duration", 0L)
|
||||
if (extraPosition > 0L) {
|
||||
Timber.d(
|
||||
"Playback stopped [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]",
|
||||
)
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE, "$extraPosition")
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
} else {
|
||||
if (extraDuration == 0L && extraPosition == 0L) {
|
||||
Timber.d("Playback completed [player=$player]")
|
||||
notifyEvent(Constants.EVENT_TIME_UPDATE)
|
||||
notifyEvent(Constants.EVENT_ENDED)
|
||||
} else {
|
||||
Timber.d(
|
||||
"Invalid state [player=$player, extraPosition=$extraPosition, extraDuration=$extraDuration]",
|
||||
)
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Timber.d("Playback failed [player=$player, resultCode=$resultCode]")
|
||||
notifyEvent(Constants.EVENT_CANCELED)
|
||||
context.toast(R.string.external_player_unknown_error, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* To ensure that the correct activity is called.
|
||||
*/
|
||||
private fun getComponent(@ExternalPlayerPackage packageName: String): ComponentName? {
|
||||
return when (packageName) {
|
||||
ExternalPlayerPackage.MPV_PLAYER -> {
|
||||
ComponentName(packageName, "$packageName.MPVActivity")
|
||||
}
|
||||
ExternalPlayerPackage.MX_PLAYER_FREE, ExternalPlayerPackage.MX_PLAYER_PRO -> {
|
||||
ComponentName(packageName, "$packageName.ActivityScreen")
|
||||
}
|
||||
ExternalPlayerPackage.VLC_PLAYER -> {
|
||||
ComponentName(packageName, "$packageName.StartActivity")
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val ALLOWED_EVENTS = arrayOf(
|
||||
Constants.EVENT_CANCELED,
|
||||
Constants.EVENT_ENDED,
|
||||
Constants.EVENT_TIME_UPDATE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package org.jellyfin.mobile.bridge
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
abstract class JavascriptCallback {
|
||||
protected abstract fun callback(keep: Boolean, err: String?, result: String?)
|
||||
|
||||
@JvmOverloads
|
||||
fun success(keep: Boolean = false, result: String? = null) = callback(keep, null, result?.let { """"$it"""" })
|
||||
|
||||
@JvmOverloads
|
||||
fun success(keep: Boolean = false, result: JSONObject?) = callback(keep, null, result.toString())
|
||||
|
||||
@JvmOverloads
|
||||
fun success(keep: Boolean = false, result: JSONArray?) = callback(keep, null, result.toString())
|
||||
|
||||
@JvmOverloads
|
||||
fun error(keep: Boolean = false, message: String) = callback(keep, """"$message"""", null)
|
||||
|
||||
@JvmOverloads
|
||||
fun error(keep: Boolean = false, error: JSONObject) = callback(keep, error.toString(), null)
|
||||
}
|
||||
171
app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
Normal file
171
app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
package org.jellyfin.mobile.bridge
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.session.PlaybackState
|
||||
import android.net.Uri
|
||||
import android.webkit.JavascriptInterface
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.jellyfin.mobile.events.ActivityEvent
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_ALBUM
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_ARTIST
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_CAN_SEEK
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_DURATION
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_IMAGE_URL
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_IS_LOCAL_PLAYER
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_IS_PAUSED
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_ITEM_ID
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_PLAYER_ACTION
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_POSITION
|
||||
import org.jellyfin.mobile.utils.Constants.EXTRA_TITLE
|
||||
import org.jellyfin.mobile.webapp.RemotePlayerService
|
||||
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("unused")
|
||||
class NativeInterface(private val context: Context) : KoinComponent {
|
||||
private val activityEventHandler: ActivityEventHandler = get()
|
||||
private val remoteVolumeProvider: RemoteVolumeProvider by inject()
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
@JavascriptInterface
|
||||
fun getDeviceInformation(): String? = try {
|
||||
val apiClient: ApiClient = get()
|
||||
val deviceInfo = apiClient.deviceInfo
|
||||
val clientInfo = apiClient.clientInfo
|
||||
|
||||
JSONObject().apply {
|
||||
put("deviceId", deviceInfo.id)
|
||||
// normalize the name by removing special characters
|
||||
// and making sure it's at least 1 character long
|
||||
// otherwise the webui will fail to send it to the server
|
||||
val name = AuthorizationHeaderBuilder.encodeParameterValue(deviceInfo.name).padStart(1)
|
||||
put("deviceName", name)
|
||||
put("appName", clientInfo.name)
|
||||
put("appVersion", clientInfo.version)
|
||||
}.toString()
|
||||
} catch (e: JSONException) {
|
||||
null
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun enableFullscreen(): Boolean {
|
||||
emitEvent(ActivityEvent.ChangeFullscreen(true))
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun disableFullscreen(): Boolean {
|
||||
emitEvent(ActivityEvent.ChangeFullscreen(false))
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openUrl(uri: String): Boolean {
|
||||
emitEvent(ActivityEvent.OpenUrl(uri))
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun updateMediaSession(args: String): Boolean {
|
||||
val options = try {
|
||||
JSONObject(args)
|
||||
} catch (e: JSONException) {
|
||||
Timber.e("updateMediaSession: %s", e.message)
|
||||
return false
|
||||
}
|
||||
val intent = Intent(context, RemotePlayerService::class.java).apply {
|
||||
action = Constants.ACTION_REPORT
|
||||
putExtra(EXTRA_PLAYER_ACTION, options.optString(EXTRA_PLAYER_ACTION))
|
||||
putExtra(EXTRA_ITEM_ID, options.optString(EXTRA_ITEM_ID))
|
||||
putExtra(EXTRA_TITLE, options.optString(EXTRA_TITLE))
|
||||
putExtra(EXTRA_ARTIST, options.optString(EXTRA_ARTIST))
|
||||
putExtra(EXTRA_ALBUM, options.optString(EXTRA_ALBUM))
|
||||
putExtra(EXTRA_IMAGE_URL, options.optString(EXTRA_IMAGE_URL))
|
||||
putExtra(EXTRA_POSITION, options.optLong(EXTRA_POSITION, PlaybackState.PLAYBACK_POSITION_UNKNOWN))
|
||||
putExtra(EXTRA_DURATION, options.optLong(EXTRA_DURATION))
|
||||
putExtra(EXTRA_CAN_SEEK, options.optBoolean(EXTRA_CAN_SEEK))
|
||||
putExtra(EXTRA_IS_LOCAL_PLAYER, options.optBoolean(EXTRA_IS_LOCAL_PLAYER, true))
|
||||
putExtra(EXTRA_IS_PAUSED, options.optBoolean(EXTRA_IS_PAUSED, true))
|
||||
}
|
||||
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
// We may need to request bluetooth permission to react to bluetooth disconnect events
|
||||
activityEventHandler.emit(ActivityEvent.RequestBluetoothPermission)
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun hideMediaSession(): Boolean {
|
||||
val intent = Intent(context, RemotePlayerService::class.java).apply {
|
||||
action = Constants.ACTION_REPORT
|
||||
putExtra(EXTRA_PLAYER_ACTION, "playbackstop")
|
||||
}
|
||||
context.startService(intent)
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun updateVolumeLevel(value: Int) {
|
||||
remoteVolumeProvider.currentVolume = value
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun downloadFiles(args: String): Boolean {
|
||||
try {
|
||||
val files = JSONArray(args)
|
||||
|
||||
repeat(files.length()) { index ->
|
||||
val file = files.getJSONObject(index)
|
||||
|
||||
val title: String = file.getString("title")
|
||||
val filename: String = file.getString("filename")
|
||||
val url: String = file.getString("url")
|
||||
|
||||
emitEvent(ActivityEvent.DownloadFile(Uri.parse(url), title, filename))
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
Timber.e("Download failed: %s", e.message)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openClientSettings() {
|
||||
emitEvent(ActivityEvent.OpenSettings)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openServerSelection() {
|
||||
emitEvent(ActivityEvent.SelectServer)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun exitApp() {
|
||||
emitEvent(ActivityEvent.ExitApp)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun execCast(action: String, args: String) {
|
||||
emitEvent(ActivityEvent.CastMessage(action, JSONArray(args)))
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
private inline fun emitEvent(event: ActivityEvent) {
|
||||
activityEventHandler.emit(event)
|
||||
}
|
||||
}
|
||||
65
app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt
Normal file
65
app/src/main/java/org/jellyfin/mobile/bridge/NativePlayer.kt
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package org.jellyfin.mobile.bridge
|
||||
|
||||
import android.webkit.JavascriptInterface
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.events.ActivityEvent
|
||||
import org.jellyfin.mobile.events.ActivityEventHandler
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||
import org.jellyfin.mobile.settings.VideoPlayerType
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.json.JSONObject
|
||||
|
||||
@Suppress("unused")
|
||||
class NativePlayer(
|
||||
private val appPreferences: AppPreferences,
|
||||
private val activityEventHandler: ActivityEventHandler,
|
||||
private val playerEventChannel: Channel<PlayerEvent>,
|
||||
) {
|
||||
|
||||
@JavascriptInterface
|
||||
fun isEnabled() = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
|
||||
|
||||
@JavascriptInterface
|
||||
fun loadPlayer(args: String) {
|
||||
PlayOptions.fromJson(JSONObject(args))?.let { options ->
|
||||
activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(options))
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun pausePlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Pause)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun resumePlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Resume)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stopPlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Stop)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun destroyPlayer() {
|
||||
playerEventChannel.trySend(PlayerEvent.Destroy)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seek(ticks: Long) {
|
||||
playerEventChannel.trySend(PlayerEvent.Seek(ticks / Constants.TICKS_PER_MILLISECOND))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun seekMs(ms: Long) {
|
||||
playerEventChannel.trySend(PlayerEvent.Seek(ms))
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun setVolume(volume: Int) {
|
||||
playerEventChannel.trySend(PlayerEvent.SetVolume(volume))
|
||||
}
|
||||
}
|
||||
17
app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
Normal file
17
app/src/main/java/org/jellyfin/mobile/data/DatabaseModule.kt
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package org.jellyfin.mobile.data
|
||||
|
||||
import androidx.room.Room
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.dsl.module
|
||||
|
||||
val databaseModule = module {
|
||||
single {
|
||||
Room.databaseBuilder(androidApplication(), JellyfinDatabase::class.java, "jellyfin")
|
||||
.addMigrations()
|
||||
.fallbackToDestructiveMigrationFrom(1)
|
||||
.fallbackToDestructiveMigrationOnDowngrade()
|
||||
.build()
|
||||
}
|
||||
single { get<JellyfinDatabase>().serverDao }
|
||||
single { get<JellyfinDatabase>().userDao }
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.jellyfin.mobile.data
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import org.jellyfin.mobile.data.dao.ServerDao
|
||||
import org.jellyfin.mobile.data.dao.UserDao
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity
|
||||
import org.jellyfin.mobile.data.entity.UserEntity
|
||||
|
||||
@Database(entities = [ServerEntity::class, UserEntity::class], version = 2)
|
||||
abstract class JellyfinDatabase : RoomDatabase() {
|
||||
abstract val serverDao: ServerDao
|
||||
abstract val userDao: UserDao
|
||||
}
|
||||
25
app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt
Normal file
25
app/src/main/java/org/jellyfin/mobile/data/dao/ServerDao.kt
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
package org.jellyfin.mobile.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME
|
||||
|
||||
@Dao
|
||||
interface ServerDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
fun insert(entity: ServerEntity): Long
|
||||
|
||||
fun insert(hostname: String) = insert(ServerEntity(hostname))
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE id = :id")
|
||||
fun getServer(id: Long): ServerEntity?
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME ORDER BY last_used_timestamp DESC")
|
||||
fun getAllServers(): List<ServerEntity>
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE hostname = :hostname")
|
||||
fun getServerByHostname(hostname: String): ServerEntity?
|
||||
}
|
||||
54
app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
Normal file
54
app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package org.jellyfin.mobile.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import org.jellyfin.mobile.data.entity.ServerUser
|
||||
import org.jellyfin.mobile.data.entity.UserEntity
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.ACCESS_TOKEN
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.ID
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID
|
||||
|
||||
@Dao
|
||||
@Suppress("TooManyFunctions")
|
||||
interface UserDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
fun insert(entity: UserEntity): Long
|
||||
|
||||
fun insert(serverId: Long, userId: String, accessToken: String?) = insert(UserEntity(serverId, userId, accessToken))
|
||||
|
||||
@Transaction
|
||||
fun upsert(serverId: Long, userId: String, accessToken: String?): Long {
|
||||
return when (val user = getByUserId(serverId, userId)) {
|
||||
null -> insert(serverId, userId, accessToken)
|
||||
else -> {
|
||||
update(user.id, accessToken)
|
||||
user.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $ID = :userId")
|
||||
fun getServerUser(serverId: Long, userId: Long): ServerUser?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId")
|
||||
fun getServerUser(serverId: Long, userId: String): ServerUser?
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId")
|
||||
fun getByUserId(serverId: Long, userId: String): UserEntity?
|
||||
|
||||
@Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId")
|
||||
fun getAllForServer(serverId: Long): List<UserEntity>
|
||||
|
||||
@Query("UPDATE $TABLE_NAME SET access_token = :accessToken WHERE $ID = :userId")
|
||||
fun update(userId: Long, accessToken: String?): Int
|
||||
|
||||
@Query("UPDATE $TABLE_NAME SET $ACCESS_TOKEN = NULL WHERE $ID = :userId")
|
||||
fun logout(userId: Long)
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package org.jellyfin.mobile.data.entity
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity.Key.HOSTNAME
|
||||
import org.jellyfin.mobile.data.entity.ServerEntity.Key.TABLE_NAME
|
||||
|
||||
@Parcelize
|
||||
@Entity(tableName = TABLE_NAME, indices = [Index(value = arrayOf(HOSTNAME), unique = true)])
|
||||
data class ServerEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = ID)
|
||||
val id: Long,
|
||||
@ColumnInfo(name = HOSTNAME)
|
||||
val hostname: String,
|
||||
@ColumnInfo(name = LAST_USED_TIMESTAMP)
|
||||
val lastUsedTimestamp: Long,
|
||||
) : Parcelable {
|
||||
constructor(hostname: String) : this(0, hostname, System.currentTimeMillis())
|
||||
|
||||
companion object Key {
|
||||
const val TABLE_NAME = "Server"
|
||||
const val ID = "id"
|
||||
const val HOSTNAME = "hostname"
|
||||
const val LAST_USED_TIMESTAMP = "last_used_timestamp"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.jellyfin.mobile.data.entity
|
||||
|
||||
import androidx.room.Embedded
|
||||
import androidx.room.Relation
|
||||
|
||||
data class ServerUser(
|
||||
@Embedded val user: UserEntity,
|
||||
@Relation(
|
||||
parentColumn = UserEntity.SERVER_ID,
|
||||
entityColumn = ServerEntity.ID,
|
||||
)
|
||||
val server: ServerEntity,
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package org.jellyfin.mobile.data.entity
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.SERVER_ID
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.TABLE_NAME
|
||||
import org.jellyfin.mobile.data.entity.UserEntity.Key.USER_ID
|
||||
|
||||
@Entity(
|
||||
tableName = TABLE_NAME,
|
||||
indices = [
|
||||
Index(value = [SERVER_ID, USER_ID], unique = true),
|
||||
],
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = ServerEntity::class, parentColumns = [ServerEntity.ID], childColumns = [SERVER_ID]),
|
||||
],
|
||||
)
|
||||
data class UserEntity(
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = ID)
|
||||
val id: Long,
|
||||
@ColumnInfo(name = SERVER_ID)
|
||||
val serverId: Long,
|
||||
@ColumnInfo(name = USER_ID)
|
||||
val userId: String,
|
||||
@ColumnInfo(name = ACCESS_TOKEN)
|
||||
val accessToken: String?,
|
||||
@ColumnInfo(name = LAST_LOGIN_TIMESTAMP)
|
||||
val lastLoginTimestamp: Long,
|
||||
) {
|
||||
constructor(serverId: Long, userId: String, accessToken: String?) :
|
||||
this(0, serverId, userId, accessToken, System.currentTimeMillis())
|
||||
|
||||
companion object Key {
|
||||
const val TABLE_NAME = "User"
|
||||
const val ID = "id"
|
||||
const val SERVER_ID = "server_id"
|
||||
const val USER_ID = "user_id"
|
||||
const val ACCESS_TOKEN = "access_token"
|
||||
const val LAST_LOGIN_TIMESTAMP = "last_login_timestamp"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package org.jellyfin.mobile.events
|
||||
|
||||
import android.net.Uri
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.json.JSONArray
|
||||
|
||||
sealed class ActivityEvent {
|
||||
class ChangeFullscreen(val isFullscreen: Boolean) : ActivityEvent()
|
||||
class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent()
|
||||
class OpenUrl(val uri: String) : ActivityEvent()
|
||||
class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent()
|
||||
class CastMessage(val action: String, val args: JSONArray) : ActivityEvent()
|
||||
data object RequestBluetoothPermission : ActivityEvent()
|
||||
data object OpenSettings : ActivityEvent()
|
||||
data object SelectServer : ActivityEvent()
|
||||
data object ExitApp : ActivityEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
package org.jellyfin.mobile.events
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.MainActivity
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.bridge.JavascriptCallback
|
||||
import org.jellyfin.mobile.player.ui.PlayerFragment
|
||||
import org.jellyfin.mobile.player.ui.PlayerFullscreenHelper
|
||||
import org.jellyfin.mobile.settings.SettingsFragment
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.extensions.addFragment
|
||||
import org.jellyfin.mobile.utils.requestDownload
|
||||
import org.jellyfin.mobile.webapp.WebappFunctionChannel
|
||||
import timber.log.Timber
|
||||
|
||||
class ActivityEventHandler(
|
||||
private val webappFunctionChannel: WebappFunctionChannel,
|
||||
) {
|
||||
private val eventsFlow = MutableSharedFlow<ActivityEvent>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
|
||||
fun MainActivity.subscribe() {
|
||||
lifecycleScope.launch {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.CREATED) {
|
||||
eventsFlow.collect { event ->
|
||||
handleEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "LongMethod")
|
||||
private fun MainActivity.handleEvent(event: ActivityEvent) {
|
||||
when (event) {
|
||||
is ActivityEvent.ChangeFullscreen -> {
|
||||
val fullscreenHelper = PlayerFullscreenHelper(window)
|
||||
if (event.isFullscreen) {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
fullscreenHelper.enableFullscreen()
|
||||
window.setBackgroundDrawable(null)
|
||||
} else {
|
||||
// Reset screen orientation
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
fullscreenHelper.disableFullscreen()
|
||||
// Reset window background color
|
||||
window.setBackgroundDrawableResource(R.color.theme_background)
|
||||
}
|
||||
}
|
||||
is ActivityEvent.LaunchNativePlayer -> {
|
||||
val args = Bundle().apply {
|
||||
putParcelable(Constants.EXTRA_MEDIA_PLAY_OPTIONS, event.playOptions)
|
||||
}
|
||||
supportFragmentManager.addFragment<PlayerFragment>(args)
|
||||
}
|
||||
is ActivityEvent.OpenUrl -> {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.uri))
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Timber.e("openIntent: %s", e.message)
|
||||
}
|
||||
}
|
||||
is ActivityEvent.DownloadFile -> {
|
||||
lifecycleScope.launch {
|
||||
with(event) { requestDownload(uri, title, filename) }
|
||||
}
|
||||
}
|
||||
is ActivityEvent.CastMessage -> {
|
||||
val action = event.action
|
||||
chromecast.execute(
|
||||
action,
|
||||
event.args,
|
||||
object : JavascriptCallback() {
|
||||
override fun callback(keep: Boolean, err: String?, result: String?) {
|
||||
webappFunctionChannel.call(
|
||||
"""window.NativeShell.castCallback("$action", $keep, $err, $result);""",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ActivityEvent.RequestBluetoothPermission -> {
|
||||
lifecycleScope.launch {
|
||||
bluetoothPermissionHelper.requestBluetoothPermissionIfNecessary()
|
||||
}
|
||||
}
|
||||
ActivityEvent.OpenSettings -> {
|
||||
supportFragmentManager.addFragment<SettingsFragment>()
|
||||
}
|
||||
ActivityEvent.SelectServer -> {
|
||||
mainViewModel.resetServer()
|
||||
}
|
||||
ActivityEvent.ExitApp -> {
|
||||
if (serviceBinder?.isPlaying == true) {
|
||||
moveTaskToBack(false)
|
||||
} else {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun emit(event: ActivityEvent) {
|
||||
eventsFlow.tryEmit(event)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package org.jellyfin.mobile.player
|
||||
|
||||
sealed class PlayerException(cause: Throwable?) : Exception(cause) {
|
||||
class InvalidPlayOptions(cause: Throwable? = null) : PlayerException(cause)
|
||||
class NetworkFailure(cause: Throwable? = null) : PlayerException(cause)
|
||||
class UnsupportedContent(cause: Throwable? = null) : PlayerException(cause)
|
||||
}
|
||||
551
app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Normal file
551
app/src/main/java/org/jellyfin/mobile/player/PlayerViewModel.kt
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
package org.jellyfin.mobile.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.DefaultRenderersFactory
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.analytics.DefaultAnalyticsCollector
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecDecoderException
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecInfo
|
||||
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import com.google.android.exoplayer2.util.Clock
|
||||
import com.google.android.exoplayer2.util.EventLogger
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
import org.jellyfin.mobile.app.PLAYER_EVENT_CHANNEL
|
||||
import org.jellyfin.mobile.player.interaction.PlayerEvent
|
||||
import org.jellyfin.mobile.player.interaction.PlayerLifecycleObserver
|
||||
import org.jellyfin.mobile.player.interaction.PlayerMediaSessionCallback
|
||||
import org.jellyfin.mobile.player.interaction.PlayerNotificationHelper
|
||||
import org.jellyfin.mobile.player.queue.QueueManager
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.ui.DecoderType
|
||||
import org.jellyfin.mobile.player.ui.DisplayPreferences
|
||||
import org.jellyfin.mobile.player.ui.PlayState
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.Constants.SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
|
||||
import org.jellyfin.mobile.utils.applyDefaultAudioAttributes
|
||||
import org.jellyfin.mobile.utils.applyDefaultLocalAudioAttributes
|
||||
import org.jellyfin.mobile.utils.extensions.scaleInRange
|
||||
import org.jellyfin.mobile.utils.extensions.width
|
||||
import org.jellyfin.mobile.utils.getVolumeLevelPercent
|
||||
import org.jellyfin.mobile.utils.getVolumeRange
|
||||
import org.jellyfin.mobile.utils.logTracks
|
||||
import org.jellyfin.mobile.utils.seekToOffset
|
||||
import org.jellyfin.mobile.utils.setPlaybackState
|
||||
import org.jellyfin.mobile.utils.toMediaMetadata
|
||||
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.api.client.extensions.hlsSegmentApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playStateApi
|
||||
import org.jellyfin.sdk.api.operations.DisplayPreferencesApi
|
||||
import org.jellyfin.sdk.api.operations.HlsSegmentApi
|
||||
import org.jellyfin.sdk.api.operations.PlayStateApi
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import org.koin.core.qualifier.named
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class PlayerViewModel(application: Application) : AndroidViewModel(application), KoinComponent, Player.Listener {
|
||||
private val apiClient: ApiClient = get()
|
||||
private val displayPreferencesApi: DisplayPreferencesApi = apiClient.displayPreferencesApi
|
||||
private val playStateApi: PlayStateApi = apiClient.playStateApi
|
||||
private val hlsSegmentApi: HlsSegmentApi = apiClient.hlsSegmentApi
|
||||
|
||||
private val lifecycleObserver = PlayerLifecycleObserver(this)
|
||||
private val audioManager: AudioManager by lazy { getApplication<Application>().getSystemService()!! }
|
||||
val notificationHelper: PlayerNotificationHelper by lazy { PlayerNotificationHelper(this) }
|
||||
|
||||
// Media source handling
|
||||
private val trackSelector = DefaultTrackSelector(getApplication())
|
||||
val trackSelectionHelper = TrackSelectionHelper(this, trackSelector)
|
||||
val queueManager = QueueManager(this)
|
||||
val mediaSourceOrNull: JellyfinMediaSource?
|
||||
get() = queueManager.currentMediaSourceOrNull
|
||||
|
||||
// ExoPlayer
|
||||
private val _player = MutableLiveData<ExoPlayer?>()
|
||||
private val _playerState = MutableLiveData<Int>()
|
||||
private val _decoderType = MutableLiveData<DecoderType>()
|
||||
val player: LiveData<ExoPlayer?> get() = _player
|
||||
val playerState: LiveData<Int> get() = _playerState
|
||||
val decoderType: LiveData<DecoderType> get() = _decoderType
|
||||
|
||||
private val _error = MutableLiveData<String>()
|
||||
val error: LiveData<String> = _error
|
||||
|
||||
private val eventLogger = EventLogger()
|
||||
private var analyticsCollector = buildAnalyticsCollector()
|
||||
private val initialTracksSelected = AtomicBoolean(false)
|
||||
private var fallbackPreferExtensionRenderers = false
|
||||
|
||||
private var progressUpdateJob: Job? = null
|
||||
|
||||
/**
|
||||
* Returns the current ExoPlayer instance or null
|
||||
*/
|
||||
val playerOrNull: ExoPlayer? get() = _player.value
|
||||
|
||||
private val playerEventChannel: Channel<PlayerEvent> by inject(named(PLAYER_EVENT_CHANNEL))
|
||||
|
||||
val mediaSession: MediaSession by lazy {
|
||||
MediaSession(
|
||||
getApplication<Application>().applicationContext,
|
||||
javaClass.simpleName.removePrefix(BuildConfig.APPLICATION_ID),
|
||||
).apply {
|
||||
@Suppress("DEPRECATION")
|
||||
setFlags(MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS or MediaSession.FLAG_HANDLES_MEDIA_BUTTONS)
|
||||
setCallback(mediaSessionCallback)
|
||||
applyDefaultLocalAudioAttributes(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
}
|
||||
}
|
||||
private val mediaSessionCallback = PlayerMediaSessionCallback(this)
|
||||
|
||||
private var displayPreferences = DisplayPreferences()
|
||||
|
||||
init {
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
|
||||
|
||||
// Load display preferences
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val displayPreferencesDto by displayPreferencesApi.getDisplayPreferences(
|
||||
displayPreferencesId = Constants.DISPLAY_PREFERENCES_ID_USER_SETTINGS,
|
||||
client = Constants.DISPLAY_PREFERENCES_CLIENT_EMBY,
|
||||
)
|
||||
|
||||
val customPrefs = displayPreferencesDto.customPrefs
|
||||
|
||||
displayPreferences = DisplayPreferences(
|
||||
skipBackLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_BACK_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
skipForwardLength = customPrefs[Constants.DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH]?.toLongOrNull()
|
||||
?: Constants.DEFAULT_SEEK_TIME_MS,
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to load display preferences")
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to player events from webapp
|
||||
viewModelScope.launch {
|
||||
for (event in playerEventChannel) {
|
||||
when (event) {
|
||||
PlayerEvent.Pause -> mediaSessionCallback.onPause()
|
||||
PlayerEvent.Resume -> mediaSessionCallback.onPlay()
|
||||
PlayerEvent.Stop, PlayerEvent.Destroy -> mediaSessionCallback.onStop()
|
||||
is PlayerEvent.Seek -> playerOrNull?.seekTo(event.ms)
|
||||
is PlayerEvent.SetVolume -> {
|
||||
setVolume(event.volume)
|
||||
playerOrNull?.reportPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildAnalyticsCollector() = DefaultAnalyticsCollector(Clock.DEFAULT).apply {
|
||||
addListener(eventLogger)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup a new [ExoPlayer] for video playback, register callbacks and set attributes
|
||||
*/
|
||||
fun setupPlayer() {
|
||||
val renderersFactory = DefaultRenderersFactory(getApplication()).apply {
|
||||
setEnableDecoderFallback(true) // Fallback only works if initialization fails, not decoding at playback time
|
||||
val rendererMode = when {
|
||||
fallbackPreferExtensionRenderers -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER
|
||||
else -> DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON
|
||||
}
|
||||
setExtensionRendererMode(rendererMode)
|
||||
setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder ->
|
||||
val decoderInfoList = MediaCodecSelector.DEFAULT.getDecoderInfos(
|
||||
mimeType,
|
||||
requiresSecureDecoder,
|
||||
requiresTunnelingDecoder,
|
||||
)
|
||||
// Allow decoder selection only for video track
|
||||
if (!MimeTypes.isVideo(mimeType)) {
|
||||
return@setMediaCodecSelector decoderInfoList
|
||||
}
|
||||
val filteredDecoderList = when (decoderType.value) {
|
||||
DecoderType.HARDWARE -> decoderInfoList.filter(MediaCodecInfo::hardwareAccelerated)
|
||||
DecoderType.SOFTWARE -> decoderInfoList.filterNot(MediaCodecInfo::hardwareAccelerated)
|
||||
else -> decoderInfoList
|
||||
}
|
||||
// Update the decoderType based on the first decoder selected
|
||||
filteredDecoderList.firstOrNull()?.let { decoder ->
|
||||
val decoderType = when {
|
||||
decoder.hardwareAccelerated -> DecoderType.HARDWARE
|
||||
else -> DecoderType.SOFTWARE
|
||||
}
|
||||
_decoderType.postValue(decoderType)
|
||||
}
|
||||
|
||||
filteredDecoderList
|
||||
}
|
||||
}
|
||||
_player.value = ExoPlayer.Builder(getApplication(), renderersFactory, get()).apply {
|
||||
setUsePlatformDiagnostics(false)
|
||||
setTrackSelector(trackSelector)
|
||||
setAnalyticsCollector(analyticsCollector)
|
||||
}.build().apply {
|
||||
addListener(this@PlayerViewModel)
|
||||
applyDefaultAudioAttributes(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the current ExoPlayer and stop/release the current MediaSession
|
||||
*/
|
||||
private fun releasePlayer() {
|
||||
notificationHelper.dismissNotification()
|
||||
mediaSession.isActive = false
|
||||
mediaSession.release()
|
||||
playerOrNull?.run {
|
||||
removeListener(this@PlayerViewModel)
|
||||
release()
|
||||
}
|
||||
_player.value = null
|
||||
}
|
||||
|
||||
fun load(jellyfinMediaSource: JellyfinMediaSource, exoMediaSource: MediaSource, playWhenReady: Boolean) {
|
||||
val player = playerOrNull ?: return
|
||||
|
||||
player.setMediaSource(exoMediaSource)
|
||||
player.prepare()
|
||||
|
||||
initialTracksSelected.set(false)
|
||||
|
||||
val startTime = jellyfinMediaSource.startTimeMs
|
||||
if (startTime > 0) player.seekTo(startTime)
|
||||
player.playWhenReady = playWhenReady
|
||||
|
||||
mediaSession.setMetadata(jellyfinMediaSource.toMediaMetadata())
|
||||
|
||||
viewModelScope.launch {
|
||||
player.reportPlaybackStart(jellyfinMediaSource)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProgressUpdates() {
|
||||
progressUpdateJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(Constants.PLAYER_TIME_UPDATE_RATE)
|
||||
playerOrNull?.reportPlaybackState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopProgressUpdates() {
|
||||
progressUpdateJob?.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the decoder of the [Player]. This will destroy the current player and
|
||||
* recreate the player with the selected decoder type
|
||||
*/
|
||||
fun updateDecoderType(type: DecoderType) {
|
||||
_decoderType.postValue(type)
|
||||
analyticsCollector.release()
|
||||
val playedTime = playerOrNull?.currentPosition ?: 0L
|
||||
// Stop and release the player without ending playback
|
||||
playerOrNull?.run {
|
||||
removeListener(this@PlayerViewModel)
|
||||
release()
|
||||
}
|
||||
analyticsCollector = buildAnalyticsCollector()
|
||||
setupPlayer()
|
||||
queueManager.currentMediaSourceOrNull?.startTimeMs = playedTime
|
||||
queueManager.tryRestartPlayback()
|
||||
}
|
||||
|
||||
private suspend fun Player.reportPlaybackStart(mediaSource: JellyfinMediaSource) {
|
||||
try {
|
||||
playStateApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = mediaSource.startTimeMs * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = audioManager.getVolumeLevelPercent(),
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback start")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun Player.reportPlaybackState() {
|
||||
val mediaSource = mediaSourceOrNull ?: return
|
||||
val playbackPositionMillis = currentPosition
|
||||
if (playbackState != Player.STATE_ENDED) {
|
||||
val stream = AudioManager.STREAM_MUSIC
|
||||
val volumeRange = audioManager.getVolumeRange(stream)
|
||||
val currentVolume = audioManager.getStreamVolume(stream)
|
||||
try {
|
||||
playStateApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
playMethod = mediaSource.playMethod,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
audioStreamIndex = mediaSource.selectedAudioStream?.index,
|
||||
subtitleStreamIndex = mediaSource.selectedSubtitleStream?.index,
|
||||
isPaused = !isPlaying,
|
||||
isMuted = false,
|
||||
canSeek = true,
|
||||
positionTicks = playbackPositionMillis * Constants.TICKS_PER_MILLISECOND,
|
||||
volumeLevel = (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback progress")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportPlaybackStop() {
|
||||
val mediaSource = mediaSourceOrNull ?: return
|
||||
val player = playerOrNull ?: return
|
||||
val hasFinished = player.playbackState == Player.STATE_ENDED
|
||||
val lastPositionTicks = when {
|
||||
hasFinished -> mediaSource.runTimeTicks
|
||||
else -> player.currentPosition * Constants.TICKS_PER_MILLISECOND
|
||||
}
|
||||
|
||||
// viewModelScope may already be cancelled at this point, so we need to fallback
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
try {
|
||||
// Report stopped playback
|
||||
playStateApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = mediaSource.itemId,
|
||||
positionTicks = lastPositionTicks,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
liveStreamId = mediaSource.liveStreamId,
|
||||
failed = false,
|
||||
),
|
||||
)
|
||||
|
||||
// Mark video as watched if playback finished
|
||||
if (hasFinished) {
|
||||
playStateApi.markPlayedItem(itemId = mediaSource.itemId)
|
||||
}
|
||||
|
||||
// Stop active encoding if transcoding
|
||||
stopTranscoding(mediaSource)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to report playback stop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun stopTranscoding(mediaSource: JellyfinMediaSource) {
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
|
||||
hlsSegmentApi.stopEncodingProcess(
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
playSessionId = mediaSource.playSessionId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Player controls
|
||||
|
||||
fun play() {
|
||||
playerOrNull?.play()
|
||||
}
|
||||
|
||||
fun pause() {
|
||||
playerOrNull?.pause()
|
||||
}
|
||||
|
||||
fun rewind() {
|
||||
playerOrNull?.seekToOffset(displayPreferences.skipBackLength.unaryMinus())
|
||||
}
|
||||
|
||||
fun fastForward() {
|
||||
playerOrNull?.seekToOffset(displayPreferences.skipForwardLength)
|
||||
}
|
||||
|
||||
fun skipToPrevious() {
|
||||
val player = playerOrNull ?: return
|
||||
when {
|
||||
// Skip to previous element
|
||||
player.currentPosition <= Constants.MAX_SKIP_TO_PREV_MS -> viewModelScope.launch {
|
||||
pause()
|
||||
if (!queueManager.previous()) {
|
||||
// Skip to previous failed, go to start of video anyway
|
||||
playerOrNull?.seekTo(0)
|
||||
play()
|
||||
}
|
||||
}
|
||||
// Rewind to start of track if not at the start already
|
||||
else -> player.seekTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun skipToNext() {
|
||||
viewModelScope.launch {
|
||||
queueManager.next()
|
||||
}
|
||||
}
|
||||
|
||||
fun getStateAndPause(): PlayState? {
|
||||
val player = playerOrNull ?: return null
|
||||
|
||||
val playWhenReady = player.playWhenReady
|
||||
player.pause()
|
||||
val position = player.contentPosition
|
||||
|
||||
return PlayState(playWhenReady, position)
|
||||
}
|
||||
|
||||
fun logTracks() {
|
||||
playerOrNull?.logTracks(analyticsCollector)
|
||||
}
|
||||
|
||||
suspend fun changeBitrate(bitrate: Int?): Boolean {
|
||||
return queueManager.changeBitrate(bitrate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playback speed to [speed]
|
||||
*
|
||||
* @return true if the speed was changed
|
||||
*/
|
||||
fun setPlaybackSpeed(speed: Float): Boolean {
|
||||
val player = playerOrNull ?: return false
|
||||
|
||||
val parameters = player.playbackParameters
|
||||
if (parameters.speed != speed) {
|
||||
player.playbackParameters = parameters.withSpeed(speed)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
pause()
|
||||
reportPlaybackStop()
|
||||
releasePlayer()
|
||||
}
|
||||
|
||||
private fun setVolume(percent: Int) {
|
||||
if (audioManager.isVolumeFixed) return
|
||||
val stream = AudioManager.STREAM_MUSIC
|
||||
val volumeRange = audioManager.getVolumeRange(stream)
|
||||
val scaled = volumeRange.scaleInRange(percent)
|
||||
audioManager.setStreamVolume(stream, scaled, 0)
|
||||
}
|
||||
|
||||
@SuppressLint("SwitchIntDef")
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
val player = playerOrNull ?: return
|
||||
|
||||
// Notify fragment of current state
|
||||
_playerState.value = playbackState
|
||||
|
||||
// Initialise various components
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
if (!initialTracksSelected.getAndSet(true)) {
|
||||
trackSelectionHelper.selectInitialTracks()
|
||||
}
|
||||
mediaSession.isActive = true
|
||||
notificationHelper.postNotification()
|
||||
}
|
||||
|
||||
// Setup or stop regular progress updates
|
||||
if (playbackState == Player.STATE_READY && playWhenReady) {
|
||||
startProgressUpdates()
|
||||
} else {
|
||||
stopProgressUpdates()
|
||||
}
|
||||
|
||||
// Update media session
|
||||
var playbackActions = SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS
|
||||
if (queueManager.hasPrevious()) {
|
||||
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_PREVIOUS
|
||||
}
|
||||
if (queueManager.hasNext()) {
|
||||
playbackActions = playbackActions or PlaybackState.ACTION_SKIP_TO_NEXT
|
||||
}
|
||||
mediaSession.setPlaybackState(player, playbackActions)
|
||||
|
||||
// Force update playback state and position
|
||||
viewModelScope.launch {
|
||||
when (playbackState) {
|
||||
Player.STATE_READY, Player.STATE_BUFFERING -> {
|
||||
player.reportPlaybackState()
|
||||
}
|
||||
Player.STATE_ENDED -> {
|
||||
reportPlaybackStop()
|
||||
if (!queueManager.next()) {
|
||||
releasePlayer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
if (error.cause is MediaCodecDecoderException && !fallbackPreferExtensionRenderers) {
|
||||
Timber.e(error.cause, "Decoder failed, attempting to restart playback with decoder extensions preferred")
|
||||
playerOrNull?.run {
|
||||
removeListener(this@PlayerViewModel)
|
||||
release()
|
||||
}
|
||||
fallbackPreferExtensionRenderers = true
|
||||
setupPlayer()
|
||||
queueManager.tryRestartPlayback()
|
||||
} else {
|
||||
_error.postValue(error.localizedMessage.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
reportPlaybackStop()
|
||||
ProcessLifecycleOwner.get().lifecycle.removeObserver(lifecycleObserver)
|
||||
releasePlayer()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
package org.jellyfin.mobile.player
|
||||
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.utils.clearSelectionAndDisableRendererByType
|
||||
import org.jellyfin.mobile.utils.selectTrackByTypeAndGroup
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
|
||||
class TrackSelectionHelper(
|
||||
private val viewModel: PlayerViewModel,
|
||||
private val trackSelector: DefaultTrackSelector,
|
||||
) {
|
||||
private val mediaSourceOrNull: JellyfinMediaSource?
|
||||
get() = viewModel.mediaSourceOrNull
|
||||
|
||||
fun selectInitialTracks() {
|
||||
val mediaSource = mediaSourceOrNull ?: return
|
||||
|
||||
mediaSource.selectedAudioStream?.let { stream ->
|
||||
selectPlayerAudioTrack(mediaSource, stream, initial = true)
|
||||
}
|
||||
selectSubtitleTrack(mediaSource, mediaSource.selectedSubtitleStream, initial = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an audio track in the media source and apply changes to the current player, if necessary and possible.
|
||||
*
|
||||
* @param mediaStreamIndex the [MediaStream.index] that should be selected
|
||||
* @return true if the audio track was changed
|
||||
*/
|
||||
suspend fun selectAudioTrack(mediaStreamIndex: Int): Boolean {
|
||||
val mediaSource = mediaSourceOrNull ?: return false
|
||||
val selectedMediaStream = mediaSource.mediaStreams[mediaStreamIndex]
|
||||
require(selectedMediaStream.type == MediaStreamType.AUDIO)
|
||||
|
||||
// For transcoding and external streams, we need to restart playback
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE || selectedMediaStream.isExternal) {
|
||||
return viewModel.queueManager.selectAudioStreamAndRestartPlayback(selectedMediaStream)
|
||||
}
|
||||
|
||||
return selectPlayerAudioTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
|
||||
if (success) viewModel.logTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the audio track in the player.
|
||||
*
|
||||
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
|
||||
* @see selectPlayerAudioTrack
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private fun selectPlayerAudioTrack(mediaSource: JellyfinMediaSource, audioStream: MediaStream, initial: Boolean): Boolean {
|
||||
if (mediaSource.playMethod == PlayMethod.TRANSCODE) {
|
||||
// Transcoding does not require explicit audio selection
|
||||
return true
|
||||
}
|
||||
|
||||
when {
|
||||
// Fast-pass: Skip execution on subsequent calls with the correct selection or if only one track exists
|
||||
mediaSource.audioStreams.size == 1 || !initial && audioStream === mediaSource.selectedAudioStream -> return true
|
||||
// Apply selection in media source, abort on failure
|
||||
!mediaSource.selectAudioStream(audioStream) -> return false
|
||||
}
|
||||
|
||||
val player = viewModel.playerOrNull ?: return false
|
||||
val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(audioStream)
|
||||
val sortedTrackGroups = player.currentTracks.groups.sortedBy { group ->
|
||||
val formatId = group.mediaTrackGroup.getFormat(0).id
|
||||
|
||||
// Sort by format ID, but pad number string with zeroes to ensure proper sorting
|
||||
formatId?.toIntOrNull()?.let { id -> "%05d".format(id) } ?: formatId
|
||||
}
|
||||
val audioGroup = sortedTrackGroups.getOrNull(embeddedStreamIndex) ?: return false
|
||||
|
||||
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_AUDIO, audioGroup.mediaTrackGroup)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a subtitle track in the media source and apply changes to the current player, if necessary.
|
||||
*
|
||||
* @param mediaStreamIndex the [MediaStream.index] that should be selected, or -1 to disable subtitles
|
||||
* @return true if the subtitle was changed
|
||||
*/
|
||||
suspend fun selectSubtitleTrack(mediaStreamIndex: Int): Boolean {
|
||||
val mediaSource = viewModel.mediaSourceOrNull ?: return false
|
||||
val selectedMediaStream = mediaSource.mediaStreams.getOrNull(mediaStreamIndex)
|
||||
require(selectedMediaStream == null || selectedMediaStream.type == MediaStreamType.SUBTITLE)
|
||||
|
||||
// If the selected subtitle stream requires encoding or the current subtitle is baked into the stream,
|
||||
// we need to restart playback
|
||||
if (
|
||||
selectedMediaStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE ||
|
||||
mediaSource.selectedSubtitleStream?.deliveryMethod == SubtitleDeliveryMethod.ENCODE
|
||||
) {
|
||||
return viewModel.queueManager.selectSubtitleStreamAndRestartPlayback(selectedMediaStream)
|
||||
}
|
||||
|
||||
return selectSubtitleTrack(mediaSource, selectedMediaStream, initial = false).also { success ->
|
||||
if (success) viewModel.logTracks()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the subtitle track in the player.
|
||||
*
|
||||
* @param initial whether this is an initial selection and checks for re-selection should be skipped.
|
||||
* @see selectSubtitleTrack
|
||||
*/
|
||||
@Suppress("ReturnCount")
|
||||
private fun selectSubtitleTrack(mediaSource: JellyfinMediaSource, subtitleStream: MediaStream?, initial: Boolean): Boolean {
|
||||
when {
|
||||
// Fast-pass: Skip execution on subsequent calls with the same selection
|
||||
!initial && subtitleStream === mediaSource.selectedSubtitleStream -> return true
|
||||
// Apply selection in media source, abort on failure
|
||||
!mediaSource.selectSubtitleStream(subtitleStream) -> return false
|
||||
}
|
||||
|
||||
// Apply selection in player
|
||||
if (subtitleStream == null) {
|
||||
// If no subtitle is selected, simply clear the selection and disable the subtitle renderer
|
||||
trackSelector.clearSelectionAndDisableRendererByType(C.TRACK_TYPE_TEXT)
|
||||
return true
|
||||
}
|
||||
|
||||
val player = viewModel.playerOrNull ?: return false
|
||||
when (subtitleStream.deliveryMethod) {
|
||||
SubtitleDeliveryMethod.ENCODE -> {
|
||||
// Normally handled in selectSubtitleTrack(int) by restarting playback,
|
||||
// initial selection is always considered successful
|
||||
return true
|
||||
}
|
||||
SubtitleDeliveryMethod.EMBED -> {
|
||||
// For embedded subtitles, we can match by the index of this stream in all embedded streams.
|
||||
val embeddedStreamIndex = mediaSource.getEmbeddedStreamIndex(subtitleStream)
|
||||
val subtitleGroup = player.currentTracks.groups.getOrNull(embeddedStreamIndex) ?: return false
|
||||
|
||||
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, subtitleGroup.mediaTrackGroup)
|
||||
}
|
||||
SubtitleDeliveryMethod.EXTERNAL -> {
|
||||
// For external subtitles, we can simply match the ID that we set when creating the player media source.
|
||||
for (group in player.currentTracks.groups) {
|
||||
if (group.getTrackFormat(0).id == "${ExternalSubtitleStream.ID_PREFIX}${subtitleStream.index}") {
|
||||
return trackSelector.selectTrackByTypeAndGroup(C.TRACK_TYPE_TEXT, group.mediaTrackGroup)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple.
|
||||
*
|
||||
* @return true if subtitles are enabled now, false if not
|
||||
*/
|
||||
suspend fun toggleSubtitles(): Boolean {
|
||||
val mediaSource = mediaSourceOrNull ?: return false
|
||||
val newSubtitleIndex = when (mediaSource.selectedSubtitleStream) {
|
||||
null -> mediaSource.subtitleStreams.firstOrNull()?.index ?: -1
|
||||
else -> -1
|
||||
}
|
||||
selectSubtitleTrack(newSubtitleIndex)
|
||||
// Media source may have changed by now
|
||||
return mediaSourceOrNull?.selectedSubtitleStream != null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
// Taken and adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/UampNotificationManager.kt
|
||||
|
||||
/*
|
||||
* Copyright 2020 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.jellyfin.mobile.player.audio
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.exoplayer2.ForwardingPlayer
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.utils.Constants.MEDIA_NOTIFICATION_CHANNEL_ID
|
||||
import org.jellyfin.mobile.utils.Constants.MEDIA_PLAYER_NOTIFICATION_ID
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
|
||||
/**
|
||||
* A wrapper class for ExoPlayer's PlayerNotificationManager. It sets up the notification shown to
|
||||
* the user during audio playback and provides track metadata, such as track title and icon image.
|
||||
*/
|
||||
class AudioNotificationManager(
|
||||
private val context: Context,
|
||||
sessionToken: MediaSessionCompat.Token,
|
||||
notificationListener: PlayerNotificationManager.NotificationListener,
|
||||
) : KoinComponent {
|
||||
private val imageLoader: ImageLoader by inject()
|
||||
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
private val notificationManager: PlayerNotificationManager
|
||||
|
||||
init {
|
||||
val mediaController = MediaControllerCompat(context, sessionToken)
|
||||
|
||||
notificationManager = PlayerNotificationManager
|
||||
.Builder(context, MEDIA_PLAYER_NOTIFICATION_ID, MEDIA_NOTIFICATION_CHANNEL_ID)
|
||||
.setChannelNameResourceId(R.string.music_notification_channel)
|
||||
.setChannelDescriptionResourceId(R.string.music_notification_channel_description)
|
||||
.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
|
||||
.setNotificationListener(notificationListener)
|
||||
.build()
|
||||
|
||||
notificationManager.apply {
|
||||
setMediaSessionToken(sessionToken)
|
||||
setSmallIcon(R.drawable.ic_notification)
|
||||
}
|
||||
}
|
||||
|
||||
fun showNotificationForPlayer(player: Player) {
|
||||
notificationManager.setPlayer(NotificationForwardingPlayer(player))
|
||||
}
|
||||
|
||||
fun hideNotification() {
|
||||
notificationManager.setPlayer(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes rewind and fast-forward buttons from notification
|
||||
*/
|
||||
private class NotificationForwardingPlayer(player: Player) : ForwardingPlayer(player) {
|
||||
override fun getAvailableCommands(): Player.Commands = super.getAvailableCommands().buildUpon().removeAll(
|
||||
COMMAND_SEEK_BACK,
|
||||
COMMAND_SEEK_FORWARD,
|
||||
).build()
|
||||
}
|
||||
|
||||
private inner class DescriptionAdapter(
|
||||
private val controller: MediaControllerCompat,
|
||||
) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
||||
controller.sessionActivity
|
||||
|
||||
override fun getCurrentContentText(player: Player) =
|
||||
controller.metadata.description.subtitle.toString()
|
||||
|
||||
override fun getCurrentContentTitle(player: Player) =
|
||||
controller.metadata.description.title.toString()
|
||||
|
||||
override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? {
|
||||
val iconUri = controller.metadata.description.iconUri
|
||||
return when {
|
||||
currentIconUri != iconUri || currentBitmap == null -> {
|
||||
// Cache the bitmap for the current song so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = iconUri
|
||||
serviceScope.launch {
|
||||
currentBitmap = iconUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
}
|
||||
else -> currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? = withContext(Dispatchers.IO) {
|
||||
imageLoader.execute(ImageRequest.Builder(context).data(uri).build()).drawable?.toBitmap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,429 @@
|
|||
// Contains code adapted from https://github.com/android/uamp/blob/main/common/src/main/java/com/example/android/uamp/media/MediaService.kt
|
||||
|
||||
package org.jellyfin.mobile.player.audio
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.mediarouter.media.MediaControlIntent
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import androidx.mediarouter.media.MediaRouterParams
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.ApiClientController
|
||||
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
|
||||
import org.jellyfin.mobile.player.audio.car.LibraryPage
|
||||
import org.jellyfin.mobile.player.cast.CastPlayerProvider
|
||||
import org.jellyfin.mobile.player.cast.ICastPlayerProvider
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.extensions.mediaUri
|
||||
import org.jellyfin.mobile.utils.toast
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.android.inject
|
||||
import timber.log.Timber
|
||||
import com.google.android.exoplayer2.MediaItem as ExoPlayerMediaItem
|
||||
|
||||
class MediaService : MediaBrowserServiceCompat() {
|
||||
private val apiClientController: ApiClientController by inject()
|
||||
private val libraryBrowser: LibraryBrowser by inject()
|
||||
|
||||
private val serviceScope = MainScope()
|
||||
private var isForegroundService = false
|
||||
|
||||
private lateinit var loadingJob: Job
|
||||
|
||||
// The current player will either be an ExoPlayer (for local playback) or a CastPlayer (for
|
||||
// remote playback through a Cast device).
|
||||
private lateinit var currentPlayer: Player
|
||||
|
||||
private lateinit var notificationManager: AudioNotificationManager
|
||||
private lateinit var mediaController: MediaControllerCompat
|
||||
private lateinit var mediaSession: MediaSessionCompat
|
||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||
private lateinit var mediaRouteSelector: MediaRouteSelector
|
||||
private lateinit var mediaRouter: MediaRouter
|
||||
private val mediaRouterCallback = MediaRouterCallback()
|
||||
|
||||
private var currentPlaylistItems: List<MediaMetadataCompat> = emptyList()
|
||||
|
||||
private val playerAudioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
val playerListener: Player.Listener = PlayerEventListener()
|
||||
|
||||
private val exoPlayer: Player by lazy {
|
||||
ExoPlayer.Builder(this, get<MediaSource.Factory>()).apply {
|
||||
setUsePlatformDiagnostics(false)
|
||||
}.build().apply {
|
||||
setAudioAttributes(playerAudioAttributes, true)
|
||||
setHandleAudioBecomingNoisy(true)
|
||||
addListener(playerListener)
|
||||
}
|
||||
}
|
||||
|
||||
private val castPlayerProvider: ICastPlayerProvider by lazy {
|
||||
CastPlayerProvider(this)
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
loadingJob = serviceScope.launch {
|
||||
apiClientController.loadSavedServerUser()
|
||||
}
|
||||
|
||||
val sessionActivityPendingIntent = packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
|
||||
PendingIntent.getActivity(this, 0, sessionIntent, Constants.PENDING_INTENT_FLAGS)
|
||||
}
|
||||
|
||||
mediaSession = MediaSessionCompat(this, "MediaService").apply {
|
||||
setSessionActivity(sessionActivityPendingIntent)
|
||||
isActive = true
|
||||
}
|
||||
|
||||
sessionToken = mediaSession.sessionToken
|
||||
|
||||
notificationManager = AudioNotificationManager(
|
||||
this,
|
||||
mediaSession.sessionToken,
|
||||
PlayerNotificationListener(),
|
||||
)
|
||||
|
||||
mediaController = MediaControllerCompat(this, mediaSession)
|
||||
|
||||
mediaSessionConnector = MediaSessionConnector(mediaSession).apply {
|
||||
setPlayer(exoPlayer)
|
||||
setPlaybackPreparer(MediaPlaybackPreparer())
|
||||
setQueueNavigator(MediaQueueNavigator(mediaSession))
|
||||
}
|
||||
|
||||
mediaRouter = MediaRouter.getInstance(this)
|
||||
mediaRouter.setMediaSessionCompat(mediaSession)
|
||||
mediaRouteSelector = MediaRouteSelector.Builder().apply {
|
||||
addControlCategory(MediaControlIntent.CATEGORY_REMOTE_PLAYBACK)
|
||||
}.build()
|
||||
mediaRouter.routerParams = MediaRouterParams.Builder().apply {
|
||||
setTransferToLocalEnabled(true)
|
||||
}.build()
|
||||
mediaRouter.addCallback(mediaRouteSelector, mediaRouterCallback, MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY)
|
||||
|
||||
switchToPlayer(
|
||||
previousPlayer = null,
|
||||
newPlayer = if (castPlayerProvider.isCastSessionAvailable) castPlayerProvider.get()!! else exoPlayer,
|
||||
)
|
||||
notificationManager.showNotificationForPlayer(currentPlayer)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSession.run {
|
||||
isActive = false
|
||||
release()
|
||||
}
|
||||
|
||||
// Cancel coroutines when the service is going away
|
||||
serviceScope.cancel()
|
||||
|
||||
// Free ExoPlayer resources
|
||||
exoPlayer.removeListener(playerListener)
|
||||
exoPlayer.release()
|
||||
|
||||
// Stop listening for route changes.
|
||||
mediaRouter.removeCallback(mediaRouterCallback)
|
||||
}
|
||||
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?,
|
||||
): BrowserRoot = libraryBrowser.getRoot(rootHints)
|
||||
|
||||
override fun onLoadChildren(parentId: String, result: Result<List<MediaItem>>) {
|
||||
result.detach()
|
||||
|
||||
serviceScope.launch(Dispatchers.IO) {
|
||||
// Ensure that server and credentials are available
|
||||
loadingJob.join()
|
||||
|
||||
val items = try {
|
||||
libraryBrowser.loadLibrary(parentId)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
result.sendResult(items ?: emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the supplied list of songs and the song to play into the current player.
|
||||
*/
|
||||
private fun preparePlaylist(
|
||||
metadataList: List<MediaMetadataCompat>,
|
||||
initialPlaybackIndex: Int = 0,
|
||||
playWhenReady: Boolean,
|
||||
playbackStartPositionMs: Long = 0,
|
||||
) {
|
||||
currentPlaylistItems = metadataList
|
||||
|
||||
val mediaItems = metadataList.map { metadata ->
|
||||
ExoPlayerMediaItem.Builder().apply {
|
||||
setUri(metadata.mediaUri)
|
||||
setTag(metadata)
|
||||
}.build()
|
||||
}
|
||||
|
||||
currentPlayer.playWhenReady = playWhenReady
|
||||
with(currentPlayer) {
|
||||
stop()
|
||||
clearMediaItems()
|
||||
}
|
||||
if (currentPlayer == exoPlayer) {
|
||||
with(exoPlayer) {
|
||||
setMediaItems(mediaItems)
|
||||
prepare()
|
||||
seekTo(initialPlaybackIndex, playbackStartPositionMs)
|
||||
}
|
||||
} else {
|
||||
val castPlayer = castPlayerProvider.get()
|
||||
if (currentPlayer == castPlayer) {
|
||||
castPlayer.setMediaItems(
|
||||
mediaItems,
|
||||
initialPlaybackIndex,
|
||||
playbackStartPositionMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun switchToPlayer(previousPlayer: Player?, newPlayer: Player) {
|
||||
if (previousPlayer == newPlayer) {
|
||||
return
|
||||
}
|
||||
currentPlayer = newPlayer
|
||||
if (previousPlayer != null) {
|
||||
val playbackState = previousPlayer.playbackState
|
||||
if (currentPlaylistItems.isEmpty()) {
|
||||
// We are joining a playback session.
|
||||
// Loading the session from the new player is not supported, so we stop playback.
|
||||
with(currentPlayer) {
|
||||
stop()
|
||||
clearMediaItems()
|
||||
}
|
||||
} else if (playbackState != Player.STATE_IDLE && playbackState != Player.STATE_ENDED) {
|
||||
preparePlaylist(
|
||||
metadataList = currentPlaylistItems,
|
||||
initialPlaybackIndex = previousPlayer.currentMediaItemIndex,
|
||||
playWhenReady = previousPlayer.playWhenReady,
|
||||
playbackStartPositionMs = previousPlayer.currentPosition,
|
||||
)
|
||||
}
|
||||
}
|
||||
mediaSessionConnector.setPlayer(newPlayer)
|
||||
previousPlayer?.run {
|
||||
stop()
|
||||
clearMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPlaybackError() {
|
||||
val errorState = PlaybackStateCompat.Builder()
|
||||
.setState(PlaybackStateCompat.STATE_ERROR, 0, 1f)
|
||||
.setErrorMessage(
|
||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED,
|
||||
getString(R.string.media_service_item_not_found),
|
||||
)
|
||||
.build()
|
||||
mediaSession.setPlaybackState(errorState)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun onCastSessionAvailable() {
|
||||
val castPlayer = castPlayerProvider.get() ?: return
|
||||
switchToPlayer(currentPlayer, castPlayer)
|
||||
}
|
||||
|
||||
@Suppress("unused")
|
||||
fun onCastSessionUnavailable() {
|
||||
switchToPlayer(currentPlayer, exoPlayer)
|
||||
}
|
||||
|
||||
private inner class MediaQueueNavigator(mediaSession: MediaSessionCompat) : TimelineQueueNavigator(mediaSession) {
|
||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat =
|
||||
currentPlaylistItems[windowIndex].description
|
||||
}
|
||||
|
||||
private inner class MediaPlaybackPreparer : MediaSessionConnector.PlaybackPreparer {
|
||||
override fun getSupportedPrepareActions(): Long = 0L or
|
||||
PlaybackStateCompat.ACTION_PREPARE or
|
||||
PlaybackStateCompat.ACTION_PLAY or
|
||||
PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
|
||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||
|
||||
override fun onPrepare(playWhenReady: Boolean) {
|
||||
serviceScope.launch {
|
||||
val recents = try {
|
||||
libraryBrowser.getDefaultRecents()
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
if (recents != null) {
|
||||
preparePlaylist(recents, 0, playWhenReady)
|
||||
} else {
|
||||
setPlaybackError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
if (mediaId == LibraryPage.RESUME) {
|
||||
// Requested recents
|
||||
onPrepare(playWhenReady)
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
val result = libraryBrowser.buildPlayQueue(mediaId)
|
||||
if (result != null) {
|
||||
val (playbackQueue, initialPlaybackIndex) = result
|
||||
preparePlaylist(playbackQueue, initialPlaybackIndex, playWhenReady)
|
||||
} else {
|
||||
setPlaybackError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
if (query.isEmpty()) {
|
||||
// No search provided, fallback to recents
|
||||
onPrepare(playWhenReady)
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
val results = try {
|
||||
libraryBrowser.getSearchResults(query, extras)
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e)
|
||||
null
|
||||
}
|
||||
if (results != null) {
|
||||
preparePlaylist(results, 0, playWhenReady)
|
||||
} else {
|
||||
setPlaybackError()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) = Unit
|
||||
|
||||
override fun onCommand(
|
||||
player: Player,
|
||||
command: String,
|
||||
extras: Bundle?,
|
||||
cb: ResultReceiver?,
|
||||
): Boolean = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for notification events.
|
||||
*/
|
||||
private inner class PlayerNotificationListener : PlayerNotificationManager.NotificationListener {
|
||||
override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) {
|
||||
if (ongoing && !isForegroundService) {
|
||||
val serviceIntent = Intent(applicationContext, this@MediaService.javaClass)
|
||||
ContextCompat.startForegroundService(applicationContext, serviceIntent)
|
||||
|
||||
startForeground(notificationId, notification)
|
||||
isForegroundService = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
|
||||
stopForeground(true)
|
||||
isForegroundService = false
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for events from ExoPlayer.
|
||||
*/
|
||||
private inner class PlayerEventListener : Player.Listener {
|
||||
override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
|
||||
when (playbackState) {
|
||||
Player.STATE_BUFFERING,
|
||||
Player.STATE_READY,
|
||||
-> {
|
||||
notificationManager.showNotificationForPlayer(currentPlayer)
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
// TODO: When playing/paused save the current media item in persistent storage
|
||||
// so that playback can be resumed between device reboots
|
||||
|
||||
if (!playWhenReady) {
|
||||
// If playback is paused we remove the foreground state which allows the
|
||||
// notification to be dismissed. An alternative would be to provide a
|
||||
// "close" button in the notification which stops playback and clears
|
||||
// the notification.
|
||||
stopForeground(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> notificationManager.hideNotification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
toast("${getString(R.string.media_service_generic_error)}: ${error.errorCodeName}", Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for MediaRoute changes
|
||||
*/
|
||||
private inner class MediaRouterCallback : MediaRouter.Callback() {
|
||||
override fun onRouteSelected(router: MediaRouter, route: MediaRouter.RouteInfo, reason: Int) {
|
||||
if (reason == MediaRouter.UNSELECT_REASON_ROUTE_CHANGED) {
|
||||
Timber.d("Unselected because route changed, continue playback")
|
||||
} else if (reason == MediaRouter.UNSELECT_REASON_STOPPED) {
|
||||
Timber.d("Unselected because route was stopped, stop playback")
|
||||
currentPlayer.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Declares that content style is supported */
|
||||
const val CONTENT_STYLE_SUPPORTED = "android.media.browse.CONTENT_STYLE_SUPPORTED"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
package org.jellyfin.mobile.player.audio.car
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.player.audio.MediaService
|
||||
import org.jellyfin.mobile.utils.extensions.mediaId
|
||||
import org.jellyfin.mobile.utils.extensions.setAlbum
|
||||
import org.jellyfin.mobile.utils.extensions.setAlbumArtUri
|
||||
import org.jellyfin.mobile.utils.extensions.setAlbumArtist
|
||||
import org.jellyfin.mobile.utils.extensions.setArtist
|
||||
import org.jellyfin.mobile.utils.extensions.setDisplayIconUri
|
||||
import org.jellyfin.mobile.utils.extensions.setMediaId
|
||||
import org.jellyfin.mobile.utils.extensions.setMediaUri
|
||||
import org.jellyfin.mobile.utils.extensions.setTitle
|
||||
import org.jellyfin.mobile.utils.extensions.setTrackNumber
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.genresApi
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.playlistsApi
|
||||
import org.jellyfin.sdk.api.client.extensions.universalAudioApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
import org.jellyfin.sdk.api.operations.GenresApi
|
||||
import org.jellyfin.sdk.api.operations.ImageApi
|
||||
import org.jellyfin.sdk.api.operations.ItemsApi
|
||||
import org.jellyfin.sdk.api.operations.PlaylistsApi
|
||||
import org.jellyfin.sdk.api.operations.UniversalAudioApi
|
||||
import org.jellyfin.sdk.api.operations.UserViewsApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import org.jellyfin.sdk.model.serializer.toUUID
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.*
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class LibraryBrowser(
|
||||
private val context: Context,
|
||||
private val apiClient: ApiClient,
|
||||
) {
|
||||
private val itemsApi: ItemsApi = apiClient.itemsApi
|
||||
private val userViewsApi: UserViewsApi = apiClient.userViewsApi
|
||||
private val genresApi: GenresApi = apiClient.genresApi
|
||||
private val playlistsApi: PlaylistsApi = apiClient.playlistsApi
|
||||
private val imageApi: ImageApi = apiClient.imageApi
|
||||
private val universalAudioApi: UniversalAudioApi = apiClient.universalAudioApi
|
||||
|
||||
fun getRoot(hints: Bundle?): MediaBrowserServiceCompat.BrowserRoot {
|
||||
/**
|
||||
* By default return the browsable root. Treat the EXTRA_RECENT flag as a special case
|
||||
* and return the recent root instead.
|
||||
*/
|
||||
val isRecentRequest = hints?.getBoolean(MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT) ?: false
|
||||
val browserRoot = if (isRecentRequest) LibraryPage.RESUME else LibraryPage.LIBRARIES
|
||||
|
||||
val rootExtras = Bundle().apply {
|
||||
putBoolean(MediaService.CONTENT_STYLE_SUPPORTED, true)
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
|
||||
)
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM,
|
||||
)
|
||||
}
|
||||
return MediaBrowserServiceCompat.BrowserRoot(browserRoot, rootExtras)
|
||||
}
|
||||
|
||||
suspend fun loadLibrary(parentId: String): List<MediaBrowserCompat.MediaItem>? {
|
||||
if (parentId == LibraryPage.RESUME) {
|
||||
return getDefaultRecents()?.browsable()
|
||||
}
|
||||
|
||||
val split = parentId.split('|')
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
if (split.size !in 1..3) {
|
||||
Timber.e("Invalid libraryId format '$parentId'")
|
||||
return null
|
||||
}
|
||||
|
||||
val type = split[0]
|
||||
val libraryId = split.getOrNull(1)?.toUUIDOrNull()
|
||||
val itemId = split.getOrNull(2)?.toUUIDOrNull()
|
||||
|
||||
return when {
|
||||
libraryId != null -> {
|
||||
when {
|
||||
itemId != null -> when (type) {
|
||||
LibraryPage.ARTIST_ALBUMS -> getAlbums(libraryId, filterArtist = itemId)
|
||||
LibraryPage.GENRE_ALBUMS -> getAlbums(libraryId, filterGenre = itemId)
|
||||
else -> null
|
||||
}
|
||||
else -> when (type) {
|
||||
LibraryPage.LIBRARY -> getLibraryViews(context, libraryId)
|
||||
LibraryPage.RECENTS -> getRecents(libraryId)?.playable()
|
||||
LibraryPage.ALBUMS -> getAlbums(libraryId)
|
||||
LibraryPage.ARTISTS -> getArtists(libraryId)
|
||||
LibraryPage.GENRES -> getGenres(libraryId)
|
||||
LibraryPage.PLAYLISTS -> getPlaylists(libraryId)
|
||||
LibraryPage.ALBUM -> getAlbum(libraryId)?.playable()
|
||||
LibraryPage.PLAYLIST -> getPlaylist(libraryId)?.playable()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> when (type) {
|
||||
LibraryPage.LIBRARIES -> getLibraries()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun buildPlayQueue(mediaId: String): Pair<List<MediaMetadataCompat>, Int>? {
|
||||
val split = mediaId.split('|')
|
||||
@Suppress("MagicNumber")
|
||||
if (split.size != 3) {
|
||||
Timber.e("Invalid mediaId format '$mediaId'")
|
||||
return null
|
||||
}
|
||||
|
||||
val type = split[0]
|
||||
val collectionId = split[1].toUUID()
|
||||
|
||||
val playQueue = try {
|
||||
when (type) {
|
||||
LibraryPage.RECENTS -> getRecents(collectionId)
|
||||
LibraryPage.ALBUM -> getAlbum(collectionId)
|
||||
LibraryPage.PLAYLIST -> getPlaylist(collectionId)
|
||||
else -> null
|
||||
}
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e)
|
||||
null
|
||||
} ?: return null
|
||||
|
||||
val playIndex = playQueue.indexOfFirst { item ->
|
||||
item.mediaId == mediaId
|
||||
}.coerceAtLeast(0)
|
||||
|
||||
return playQueue to playIndex
|
||||
}
|
||||
|
||||
suspend fun getSearchResults(searchQuery: String, extras: Bundle?): List<MediaMetadataCompat>? {
|
||||
when (extras?.getString(MediaStore.EXTRA_MEDIA_FOCUS)) {
|
||||
MediaStore.Audio.Albums.ENTRY_CONTENT_TYPE -> {
|
||||
// Search for specific album
|
||||
extras.getString(MediaStore.EXTRA_MEDIA_ALBUM)?.let { albumQuery ->
|
||||
Timber.d("Searching for album $albumQuery")
|
||||
searchItems(albumQuery, BaseItemKind.MUSIC_ALBUM)
|
||||
}?.let { albumId ->
|
||||
getAlbum(albumId)
|
||||
}?.let { albumContent ->
|
||||
Timber.d("Got result, starting playback")
|
||||
return albumContent
|
||||
}
|
||||
}
|
||||
MediaStore.Audio.Artists.ENTRY_CONTENT_TYPE -> {
|
||||
// Search for specific artist
|
||||
extras.getString(MediaStore.EXTRA_MEDIA_ARTIST)?.let { artistQuery ->
|
||||
Timber.d("Searching for artist $artistQuery")
|
||||
searchItems(artistQuery, BaseItemKind.MUSIC_ARTIST)
|
||||
}?.let { artistId ->
|
||||
itemsApi.getItems(
|
||||
artistIds = listOf(artistId),
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
sortBy = listOf(ItemSortBy.RANDOM),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
enableTotalRecordCount = false,
|
||||
limit = 50,
|
||||
).content.extractItems()
|
||||
}?.let { artistTracks ->
|
||||
Timber.d("Got result, starting playback")
|
||||
return artistTracks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generic search
|
||||
Timber.d("Searching for '$searchQuery'")
|
||||
val result by itemsApi.getItems(
|
||||
searchTerm = searchQuery,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
enableTotalRecordCount = false,
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
return result.extractItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single specific item for the given [searchQuery] with a specific [type]
|
||||
*/
|
||||
private suspend fun searchItems(searchQuery: String, type: BaseItemKind): UUID? {
|
||||
val result by itemsApi.getItems(
|
||||
searchTerm = searchQuery,
|
||||
includeItemTypes = listOf(type),
|
||||
recursive = true,
|
||||
enableImages = false,
|
||||
enableTotalRecordCount = false,
|
||||
limit = 1,
|
||||
)
|
||||
|
||||
return result.items.firstOrNull()?.id
|
||||
}
|
||||
|
||||
suspend fun getDefaultRecents(): List<MediaMetadataCompat>? = getLibraries().firstOrNull()?.mediaId?.let { defaultLibrary ->
|
||||
val libraryId = defaultLibrary.split('|').getOrNull(1) ?: return@let null
|
||||
|
||||
getRecents(libraryId.toUUID())
|
||||
}
|
||||
|
||||
private suspend fun getLibraries(): List<MediaBrowserCompat.MediaItem> {
|
||||
val userViews by userViewsApi.getUserViews()
|
||||
|
||||
return userViews.items
|
||||
.filter { item -> item.collectionType == CollectionType.MUSIC }
|
||||
.map { item ->
|
||||
val itemImageUrl = imageApi.getItemImageUrl(
|
||||
itemId = item.id,
|
||||
imageType = ImageType.PRIMARY,
|
||||
)
|
||||
|
||||
val description = MediaDescriptionCompat.Builder().apply {
|
||||
setMediaId(LibraryPage.LIBRARY + "|" + item.id)
|
||||
setTitle(item.name)
|
||||
setIconUri(Uri.parse(itemImageUrl))
|
||||
}.build()
|
||||
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun getLibraryViews(context: Context, libraryId: UUID): List<MediaBrowserCompat.MediaItem> {
|
||||
val libraryViews = arrayOf(
|
||||
LibraryPage.RECENTS to R.string.media_service_car_section_recents,
|
||||
LibraryPage.ALBUMS to R.string.media_service_car_section_albums,
|
||||
LibraryPage.ARTISTS to R.string.media_service_car_section_artists,
|
||||
LibraryPage.GENRES to R.string.media_service_car_section_genres,
|
||||
LibraryPage.PLAYLISTS to R.string.media_service_car_section_playlists,
|
||||
)
|
||||
return libraryViews.map { item ->
|
||||
val description = MediaDescriptionCompat.Builder().apply {
|
||||
setMediaId(item.first + "|" + libraryId)
|
||||
setTitle(context.getString(item.second))
|
||||
|
||||
if (item.first == LibraryPage.ALBUMS) {
|
||||
setExtras(
|
||||
Bundle().apply {
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
|
||||
)
|
||||
putInt(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}.build()
|
||||
MediaBrowserCompat.MediaItem(description, FLAG_BROWSABLE)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getRecents(libraryId: UUID): List<MediaMetadataCompat>? {
|
||||
val result by itemsApi.getItems(
|
||||
parentId = libraryId,
|
||||
includeItemTypes = listOf(BaseItemKind.AUDIO),
|
||||
filters = listOf(ItemFilter.IS_PLAYED),
|
||||
sortBy = listOf(ItemSortBy.DATE_PLAYED),
|
||||
sortOrder = listOf(SortOrder.DESCENDING),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
enableTotalRecordCount = false,
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
return result.extractItems("${LibraryPage.RECENTS}|$libraryId")
|
||||
}
|
||||
|
||||
private suspend fun getAlbums(
|
||||
libraryId: UUID,
|
||||
filterArtist: UUID? = null,
|
||||
filterGenre: UUID? = null,
|
||||
): List<MediaBrowserCompat.MediaItem>? {
|
||||
val result by itemsApi.getItems(
|
||||
parentId = libraryId,
|
||||
artistIds = filterArtist?.let(::listOf),
|
||||
genreIds = filterGenre?.let(::listOf),
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ALBUM),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
limit = 400,
|
||||
)
|
||||
|
||||
return result.extractItems()?.browsable()
|
||||
}
|
||||
|
||||
private suspend fun getArtists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
|
||||
val result by itemsApi.getItems(
|
||||
parentId = libraryId,
|
||||
includeItemTypes = listOf(BaseItemKind.MUSIC_ARTIST),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
limit = 200,
|
||||
)
|
||||
|
||||
return result.extractItems(libraryId.toString())?.browsable()
|
||||
}
|
||||
|
||||
private suspend fun getGenres(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
|
||||
val result by genresApi.getGenres(
|
||||
parentId = libraryId,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
return result.extractItems(libraryId.toString())?.browsable()
|
||||
}
|
||||
|
||||
private suspend fun getPlaylists(libraryId: UUID): List<MediaBrowserCompat.MediaItem>? {
|
||||
val result by itemsApi.getItems(
|
||||
parentId = libraryId,
|
||||
includeItemTypes = listOf(BaseItemKind.PLAYLIST),
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||
recursive = true,
|
||||
imageTypeLimit = 1,
|
||||
enableImageTypes = listOf(ImageType.PRIMARY),
|
||||
limit = 50,
|
||||
)
|
||||
|
||||
return result.extractItems()?.browsable()
|
||||
}
|
||||
|
||||
private suspend fun getAlbum(albumId: UUID): List<MediaMetadataCompat>? {
|
||||
val result by itemsApi.getItems(
|
||||
parentId = albumId,
|
||||
sortBy = listOf(ItemSortBy.SORT_NAME),
|
||||
)
|
||||
|
||||
return result.extractItems("${LibraryPage.ALBUM}|$albumId")
|
||||
}
|
||||
|
||||
private suspend fun getPlaylist(playlistId: UUID): List<MediaMetadataCompat>? {
|
||||
val result by playlistsApi.getPlaylistItems(
|
||||
playlistId = playlistId,
|
||||
)
|
||||
|
||||
return result.extractItems("${LibraryPage.PLAYLIST}|$playlistId")
|
||||
}
|
||||
|
||||
private fun BaseItemDtoQueryResult.extractItems(libraryId: String? = null): List<MediaMetadataCompat>? =
|
||||
items?.map { item -> buildMediaMetadata(item, libraryId) }?.toList()
|
||||
|
||||
private fun buildMediaMetadata(item: BaseItemDto, libraryId: String?): MediaMetadataCompat {
|
||||
val builder = MediaMetadataCompat.Builder()
|
||||
builder.setMediaId(buildMediaId(item, libraryId))
|
||||
builder.setTitle(item.name ?: context.getString(R.string.media_service_car_item_no_title))
|
||||
|
||||
val isAlbum = item.albumId != null
|
||||
val itemId = when {
|
||||
item.imageTags?.containsKey(ImageType.PRIMARY) == true -> item.id
|
||||
isAlbum -> item.albumId
|
||||
else -> null
|
||||
}
|
||||
val primaryImageUrl = itemId?.let {
|
||||
imageApi.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
tag = if (isAlbum) item.albumPrimaryImageTag else item.imageTags?.get(ImageType.PRIMARY),
|
||||
)
|
||||
}
|
||||
|
||||
if (item.type == BaseItemKind.AUDIO) {
|
||||
val uri = universalAudioApi.getUniversalAudioStreamUrl(
|
||||
itemId = item.id,
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
maxStreamingBitrate = 140000000,
|
||||
container = listOf(
|
||||
"opus",
|
||||
"mp3|mp3",
|
||||
"aac",
|
||||
"m4a",
|
||||
"m4b|aac",
|
||||
"flac",
|
||||
"webma",
|
||||
"webm",
|
||||
"wav",
|
||||
"ogg",
|
||||
),
|
||||
transcodingProtocol = MediaStreamProtocol.HLS,
|
||||
transcodingContainer = "ts",
|
||||
audioCodec = "aac",
|
||||
enableRemoteMedia = true,
|
||||
)
|
||||
|
||||
builder.setMediaUri(uri)
|
||||
item.album?.let(builder::setAlbum)
|
||||
item.artists?.let { builder.setArtist(it.joinToString()) }
|
||||
item.albumArtist?.let(builder::setAlbumArtist)
|
||||
primaryImageUrl?.let(builder::setAlbumArtUri)
|
||||
item.indexNumber?.toLong()?.let(builder::setTrackNumber)
|
||||
} else {
|
||||
primaryImageUrl?.let(builder::setDisplayIconUri)
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildMediaId(item: BaseItemDto, extra: String?) = when (item.type) {
|
||||
BaseItemKind.MUSIC_ARTIST -> "${LibraryPage.ARTIST_ALBUMS}|$extra|${item.id}"
|
||||
BaseItemKind.MUSIC_GENRE -> "${LibraryPage.GENRE_ALBUMS}|$extra|${item.id}"
|
||||
BaseItemKind.MUSIC_ALBUM -> "${LibraryPage.ALBUM}|${item.id}"
|
||||
BaseItemKind.PLAYLIST -> "${LibraryPage.PLAYLIST}|${item.id}"
|
||||
BaseItemKind.AUDIO -> "$extra|${item.id}"
|
||||
else -> throw IllegalArgumentException("Unhandled item type ${item.type}")
|
||||
}
|
||||
|
||||
private fun List<MediaMetadataCompat>.browsable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
|
||||
MediaBrowserCompat.MediaItem(metadata.description, FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
private fun List<MediaMetadataCompat>.playable(): List<MediaBrowserCompat.MediaItem> = map { metadata ->
|
||||
MediaBrowserCompat.MediaItem(metadata.description, FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package org.jellyfin.mobile.player.audio.car
|
||||
|
||||
object LibraryPage {
|
||||
/**
|
||||
* List of music libraries that the user can access (referred to as "user views" in Jellyfin)
|
||||
*/
|
||||
const val LIBRARIES = "libraries"
|
||||
|
||||
/**
|
||||
* Special root id for use with [EXTRA_RECENT][androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT]
|
||||
*/
|
||||
const val RESUME = "resume"
|
||||
|
||||
/**
|
||||
* A single music library
|
||||
*/
|
||||
const val LIBRARY = "library"
|
||||
|
||||
/**
|
||||
* A list of recently added tracks
|
||||
*/
|
||||
const val RECENTS = "recents"
|
||||
|
||||
/**
|
||||
* A list of albums
|
||||
*/
|
||||
const val ALBUMS = "albums"
|
||||
|
||||
/**
|
||||
* A list of artists
|
||||
*/
|
||||
const val ARTISTS = "artists"
|
||||
|
||||
/**
|
||||
* A list of albums by a specific artist
|
||||
*/
|
||||
const val ARTIST_ALBUMS = "artist_albums"
|
||||
|
||||
/**
|
||||
* A list of genres
|
||||
*/
|
||||
const val GENRES = "genres"
|
||||
|
||||
/**
|
||||
* A list of albums with a specific genre
|
||||
*/
|
||||
const val GENRE_ALBUMS = "genre_albums"
|
||||
|
||||
/**
|
||||
* A list of playlists
|
||||
*/
|
||||
const val PLAYLISTS = "playlists"
|
||||
|
||||
/**
|
||||
* An individual album
|
||||
*/
|
||||
const val ALBUM = "album"
|
||||
|
||||
/**
|
||||
* An individual playlist
|
||||
*/
|
||||
const val PLAYLIST = "playlist"
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
interface ICastPlayerProvider {
|
||||
val isCastSessionAvailable: Boolean
|
||||
|
||||
fun get(): Player?
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.jellyfin.mobile.player.cast
|
||||
|
||||
import android.app.Activity
|
||||
import org.jellyfin.mobile.bridge.JavascriptCallback
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
|
||||
interface IChromecast {
|
||||
fun initializePlugin(activity: Activity)
|
||||
|
||||
@Throws(JSONException::class)
|
||||
fun execute(action: String, args: JSONArray, cbContext: JavascriptCallback): Boolean
|
||||
|
||||
fun destroy()
|
||||
}
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
package org.jellyfin.mobile.player.deviceprofile
|
||||
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaFormat
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
|
||||
@Suppress("TooManyFunctions", "CyclomaticComplexMethod")
|
||||
object CodecHelpers {
|
||||
fun getVideoCodec(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG2 -> "mpeg2video"
|
||||
MediaFormat.MIMETYPE_VIDEO_H263 -> "h263"
|
||||
MediaFormat.MIMETYPE_VIDEO_MPEG4 -> "mpeg4"
|
||||
MediaFormat.MIMETYPE_VIDEO_AVC -> "h264"
|
||||
MediaFormat.MIMETYPE_VIDEO_HEVC, MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION -> "hevc"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP8 -> "vp8"
|
||||
MediaFormat.MIMETYPE_VIDEO_VP9 -> "vp9"
|
||||
MediaFormat.MIMETYPE_VIDEO_AV1 -> "av1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun getAudioCodec(mimeType: String): String? = when (mimeType) {
|
||||
MediaFormat.MIMETYPE_AUDIO_AAC -> "aac"
|
||||
MediaFormat.MIMETYPE_AUDIO_AC3 -> "ac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_AMR_WB, MediaFormat.MIMETYPE_AUDIO_AMR_NB -> "3gpp"
|
||||
MediaFormat.MIMETYPE_AUDIO_EAC3 -> "eac3"
|
||||
MediaFormat.MIMETYPE_AUDIO_FLAC -> "flac"
|
||||
MediaFormat.MIMETYPE_AUDIO_MPEG -> "mp3"
|
||||
MediaFormat.MIMETYPE_AUDIO_OPUS -> "opus"
|
||||
MediaFormat.MIMETYPE_AUDIO_RAW -> "raw"
|
||||
MediaFormat.MIMETYPE_AUDIO_VORBIS -> "vorbis"
|
||||
MediaFormat.MIMETYPE_AUDIO_QCELP, MediaFormat.MIMETYPE_AUDIO_MSGSM, MediaFormat.MIMETYPE_AUDIO_G711_MLAW, MediaFormat.MIMETYPE_AUDIO_G711_ALAW -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun getVideoProfile(codec: String, profile: Int): String? = when (codec) {
|
||||
"mpeg2video" -> getMPEG2VideoProfile(profile)
|
||||
"h263" -> getH263Profile(profile)
|
||||
"mpeg4" -> getMPEG4Profile(profile)
|
||||
"h264" -> getAVCProfile(profile)
|
||||
"hevc" -> getHEVCProfile(profile)
|
||||
"vp8" -> getVP8Profile(profile)
|
||||
"vp9" -> getVP9Profile(profile)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getMPEG2VideoProfile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG2ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG2ProfileMain -> "main profile"
|
||||
CodecProfileLevel.MPEG2Profile422 -> "422 profile"
|
||||
CodecProfileLevel.MPEG2ProfileSNR -> "snr profile"
|
||||
CodecProfileLevel.MPEG2ProfileSpatial -> "spatial profile"
|
||||
CodecProfileLevel.MPEG2ProfileHigh -> "high profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getH263Profile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.H263ProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.H263ProfileH320Coding -> "h320 coding"
|
||||
CodecProfileLevel.H263ProfileBackwardCompatible -> "backward compatible"
|
||||
CodecProfileLevel.H263ProfileISWV2 -> "isw v2"
|
||||
CodecProfileLevel.H263ProfileISWV3 -> "isw v3"
|
||||
CodecProfileLevel.H263ProfileHighCompression -> "high compression"
|
||||
CodecProfileLevel.H263ProfileInternet -> "internet"
|
||||
CodecProfileLevel.H263ProfileInterlace -> "interlace"
|
||||
CodecProfileLevel.H263ProfileHighLatency -> "high latency"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getMPEG4Profile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCoding -> "advanced coding profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedCore -> "advanced core profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedRealTime -> "advanced realtime profile"
|
||||
CodecProfileLevel.MPEG4ProfileAdvancedSimple -> "advanced simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileBasicAnimated -> "basic animated profile"
|
||||
CodecProfileLevel.MPEG4ProfileCore -> "core profile"
|
||||
CodecProfileLevel.MPEG4ProfileCoreScalable -> "core scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileHybrid -> "hybrid profile"
|
||||
CodecProfileLevel.MPEG4ProfileNbit -> "nbit profile"
|
||||
CodecProfileLevel.MPEG4ProfileScalableTexture -> "scalable texture profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimple -> "simple profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFBA -> "simple fba profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleFace -> "simple face profile"
|
||||
CodecProfileLevel.MPEG4ProfileSimpleScalable -> "simple scalable profile"
|
||||
CodecProfileLevel.MPEG4ProfileMain -> "main profile"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getAVCProfile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.AVCProfileBaseline -> "baseline"
|
||||
CodecProfileLevel.AVCProfileMain -> "main"
|
||||
CodecProfileLevel.AVCProfileExtended -> "extended"
|
||||
CodecProfileLevel.AVCProfileHigh -> "high"
|
||||
CodecProfileLevel.AVCProfileHigh10 -> "high 10"
|
||||
CodecProfileLevel.AVCProfileHigh422 -> "high 422"
|
||||
CodecProfileLevel.AVCProfileHigh444 -> "high 444"
|
||||
CodecProfileLevel.AVCProfileConstrainedBaseline -> "constrained baseline"
|
||||
CodecProfileLevel.AVCProfileConstrainedHigh -> "constrained high"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHEVCProfile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.HEVCProfileMain -> "Main"
|
||||
CodecProfileLevel.HEVCProfileMain10 -> "Main 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10 -> "Main 10 HDR 10"
|
||||
CodecProfileLevel.HEVCProfileMain10HDR10Plus -> "Main 10 HDR 10 Plus"
|
||||
CodecProfileLevel.HEVCProfileMainStill -> "Main Still"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getVP8Profile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP8ProfileMain -> "main"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getVP9Profile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.VP9Profile0 -> "Profile 0"
|
||||
CodecProfileLevel.VP9Profile1 -> "Profile 1"
|
||||
CodecProfileLevel.VP9Profile2,
|
||||
CodecProfileLevel.VP9Profile2HDR,
|
||||
-> "Profile 2"
|
||||
CodecProfileLevel.VP9Profile3,
|
||||
CodecProfileLevel.VP9Profile3HDR,
|
||||
-> "Profile 3"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun getVideoLevel(codec: String, level: Int): Int? = when (codec) {
|
||||
"mpeg2video" -> getMPEG2VideoLevel(level)
|
||||
"h263" -> getH263Level(level)
|
||||
"mpeg4" -> getMPEG4Level(level)
|
||||
"avc", "h264" -> getAVCLevel(level)
|
||||
"hevc" -> getHEVCLevel(level)
|
||||
"vp8" -> getVP8Level(level)
|
||||
"vp9" -> getVP9Level(level)
|
||||
else -> null
|
||||
}?.let { Integer.valueOf(it) }
|
||||
|
||||
/**
|
||||
* Level numbers taken from FFmpeg `libavcodec/mpeg12enc.c`.
|
||||
*/
|
||||
private fun getMPEG2VideoLevel(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.MPEG2LevelLL -> "10"
|
||||
CodecProfileLevel.MPEG2LevelML -> "8"
|
||||
CodecProfileLevel.MPEG2LevelH14 -> "6"
|
||||
CodecProfileLevel.MPEG2LevelHL -> "4"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getH263Level(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.H263Level10 -> "10"
|
||||
CodecProfileLevel.H263Level20 -> "20"
|
||||
CodecProfileLevel.H263Level30 -> "30"
|
||||
CodecProfileLevel.H263Level40 -> "40"
|
||||
CodecProfileLevel.H263Level45 -> "45"
|
||||
CodecProfileLevel.H263Level50 -> "50"
|
||||
CodecProfileLevel.H263Level60 -> "60"
|
||||
CodecProfileLevel.H263Level70 -> "70"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getMPEG4Level(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.MPEG4Level0 -> "0"
|
||||
CodecProfileLevel.MPEG4Level1 -> "1"
|
||||
CodecProfileLevel.MPEG4Level2 -> "2"
|
||||
CodecProfileLevel.MPEG4Level3 -> "3"
|
||||
CodecProfileLevel.MPEG4Level4 -> "4"
|
||||
CodecProfileLevel.MPEG4Level5 -> "5"
|
||||
CodecProfileLevel.MPEG4Level6 -> "6"
|
||||
CodecProfileLevel.MPEG4Level0b, CodecProfileLevel.MPEG4Level3b, CodecProfileLevel.MPEG4Level4a -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getAVCLevel(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.AVCLevel1 -> "1"
|
||||
CodecProfileLevel.AVCLevel11 -> "11"
|
||||
CodecProfileLevel.AVCLevel12 -> "12"
|
||||
CodecProfileLevel.AVCLevel13 -> "13"
|
||||
CodecProfileLevel.AVCLevel2 -> "2"
|
||||
CodecProfileLevel.AVCLevel21 -> "21"
|
||||
CodecProfileLevel.AVCLevel22 -> "22"
|
||||
CodecProfileLevel.AVCLevel3 -> "3"
|
||||
CodecProfileLevel.AVCLevel31 -> "31"
|
||||
CodecProfileLevel.AVCLevel32 -> "32"
|
||||
CodecProfileLevel.AVCLevel4 -> "4"
|
||||
CodecProfileLevel.AVCLevel41 -> "41"
|
||||
CodecProfileLevel.AVCLevel42 -> "42"
|
||||
CodecProfileLevel.AVCLevel5 -> "5"
|
||||
CodecProfileLevel.AVCLevel51 -> "51"
|
||||
CodecProfileLevel.AVCLevel52 -> "52"
|
||||
CodecProfileLevel.AVCLevel1b -> null
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getHEVCLevel(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.HEVCMainTierLevel1, CodecProfileLevel.HEVCHighTierLevel1 -> "30"
|
||||
CodecProfileLevel.HEVCMainTierLevel2, CodecProfileLevel.HEVCHighTierLevel2 -> "60"
|
||||
CodecProfileLevel.HEVCMainTierLevel21, CodecProfileLevel.HEVCHighTierLevel21 -> "63"
|
||||
CodecProfileLevel.HEVCMainTierLevel3, CodecProfileLevel.HEVCHighTierLevel3 -> "90"
|
||||
CodecProfileLevel.HEVCMainTierLevel31, CodecProfileLevel.HEVCHighTierLevel31 -> "93"
|
||||
CodecProfileLevel.HEVCMainTierLevel4, CodecProfileLevel.HEVCHighTierLevel4 -> "120"
|
||||
CodecProfileLevel.HEVCMainTierLevel41, CodecProfileLevel.HEVCHighTierLevel41 -> "123"
|
||||
CodecProfileLevel.HEVCMainTierLevel5, CodecProfileLevel.HEVCHighTierLevel5 -> "150"
|
||||
CodecProfileLevel.HEVCMainTierLevel51, CodecProfileLevel.HEVCHighTierLevel51 -> "153"
|
||||
CodecProfileLevel.HEVCMainTierLevel52, CodecProfileLevel.HEVCHighTierLevel52 -> "156"
|
||||
CodecProfileLevel.HEVCMainTierLevel6, CodecProfileLevel.HEVCHighTierLevel6 -> "180"
|
||||
CodecProfileLevel.HEVCMainTierLevel61, CodecProfileLevel.HEVCHighTierLevel61 -> "183"
|
||||
CodecProfileLevel.HEVCMainTierLevel62, CodecProfileLevel.HEVCHighTierLevel62 -> "186"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getVP8Level(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.VP8Level_Version0 -> "0"
|
||||
CodecProfileLevel.VP8Level_Version1 -> "1"
|
||||
CodecProfileLevel.VP8Level_Version2 -> "2"
|
||||
CodecProfileLevel.VP8Level_Version3 -> "3"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getVP9Level(level: Int): String? = when (level) {
|
||||
CodecProfileLevel.VP9Level1 -> "1"
|
||||
CodecProfileLevel.VP9Level11 -> "11"
|
||||
CodecProfileLevel.VP9Level2 -> "2"
|
||||
CodecProfileLevel.VP9Level21 -> "21"
|
||||
CodecProfileLevel.VP9Level3 -> "3"
|
||||
CodecProfileLevel.VP9Level31 -> "31"
|
||||
CodecProfileLevel.VP9Level4 -> "4"
|
||||
CodecProfileLevel.VP9Level41 -> "41"
|
||||
CodecProfileLevel.VP9Level5 -> "5"
|
||||
CodecProfileLevel.VP9Level51 -> "51"
|
||||
CodecProfileLevel.VP9Level52 -> "52"
|
||||
CodecProfileLevel.VP9Level6 -> "6"
|
||||
CodecProfileLevel.VP9Level61 -> "61"
|
||||
CodecProfileLevel.VP9Level62 -> "62"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mimeType for a subtitle codec if supported.
|
||||
*
|
||||
* @param codec Subtitle codec given by Jellyfin.
|
||||
* @return The mimeType or null if not supported.
|
||||
*/
|
||||
fun getSubtitleMimeType(codec: String?): String? {
|
||||
return when (codec) {
|
||||
"srt", "subrip" -> MimeTypes.APPLICATION_SUBRIP
|
||||
"ssa", "ass" -> MimeTypes.TEXT_SSA
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"idx", "sub" -> MimeTypes.APPLICATION_VOBSUB
|
||||
"pgs", "pgssub" -> MimeTypes.APPLICATION_PGS
|
||||
"smi", "smil" -> "application/smil+xml"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getAudioProfile(codec: String, profile: Int): String? = when (codec) {
|
||||
"aac" -> getAACProfile(profile)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun getAACProfile(profile: Int): String? = when (profile) {
|
||||
CodecProfileLevel.AACObjectELD -> "ELD"
|
||||
CodecProfileLevel.AACObjectHE -> "HE-AAC"
|
||||
CodecProfileLevel.AACObjectHE_PS -> "HE-AACv2"
|
||||
CodecProfileLevel.AACObjectLC -> "LC"
|
||||
CodecProfileLevel.AACObjectLD -> "LD"
|
||||
CodecProfileLevel.AACObjectLTP -> "LTP"
|
||||
CodecProfileLevel.AACObjectMain -> "Main"
|
||||
CodecProfileLevel.AACObjectSSR -> "SSR"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
package org.jellyfin.mobile.player.deviceprofile
|
||||
|
||||
import android.media.MediaCodecInfo.CodecCapabilities
|
||||
import android.util.Range
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioCodec
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getAudioProfile
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoCodec
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoLevel
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers.getVideoProfile
|
||||
import kotlin.math.max
|
||||
|
||||
sealed class DeviceCodec(
|
||||
val name: String,
|
||||
val mimeType: String,
|
||||
val profiles: Set<String>,
|
||||
val maxBitrate: Int,
|
||||
) {
|
||||
class Video(
|
||||
name: String,
|
||||
mimeType: String,
|
||||
profiles: Set<String>,
|
||||
private val levels: Set<Int>,
|
||||
maxBitrate: Int,
|
||||
) : DeviceCodec(name, mimeType, profiles, maxBitrate) {
|
||||
|
||||
fun mergeCodec(codecToMerge: Video): Video = Video(
|
||||
name = name,
|
||||
mimeType = mimeType,
|
||||
profiles = profiles + codecToMerge.profiles,
|
||||
levels = levels + codecToMerge.levels,
|
||||
maxBitrate = max(maxBitrate, codecToMerge.maxBitrate),
|
||||
)
|
||||
}
|
||||
|
||||
class Audio(
|
||||
name: String,
|
||||
mimeType: String,
|
||||
profiles: Set<String>,
|
||||
maxBitrate: Int,
|
||||
private val maxChannels: Int,
|
||||
private val maxSampleRate: Int?,
|
||||
) : DeviceCodec(name, mimeType, profiles, maxBitrate) {
|
||||
|
||||
fun mergeCodec(codecToMerge: Audio): Audio = Audio(
|
||||
name = name,
|
||||
mimeType = mimeType,
|
||||
profiles = profiles + codecToMerge.profiles,
|
||||
maxBitrate = max(maxBitrate, codecToMerge.maxBitrate),
|
||||
maxChannels = max(maxChannels, codecToMerge.maxChannels),
|
||||
maxSampleRate = when {
|
||||
maxSampleRate != null -> when {
|
||||
codecToMerge.maxSampleRate != null -> max(maxSampleRate, codecToMerge.maxSampleRate)
|
||||
else -> maxSampleRate
|
||||
}
|
||||
else -> codecToMerge.maxSampleRate
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(codecCapabilities: CodecCapabilities): DeviceCodec? {
|
||||
val mimeType = codecCapabilities.mimeType
|
||||
|
||||
// Check if this mimeType represents a video or audio codec
|
||||
val videoCodec = getVideoCodec(mimeType)
|
||||
val audioCodec = getAudioCodec(mimeType)
|
||||
return when {
|
||||
videoCodec != null -> {
|
||||
val profiles = HashSet<String>()
|
||||
val levels = HashSet<Int>()
|
||||
for (profileLevel in codecCapabilities.profileLevels) {
|
||||
getVideoProfile(videoCodec, profileLevel.profile)?.let(profiles::add)
|
||||
getVideoLevel(videoCodec, profileLevel.level)?.let(levels::add)
|
||||
}
|
||||
|
||||
Video(
|
||||
name = videoCodec,
|
||||
mimeType = mimeType,
|
||||
profiles = profiles,
|
||||
levels = levels,
|
||||
maxBitrate = codecCapabilities.videoCapabilities.bitrateRange.upper,
|
||||
)
|
||||
}
|
||||
audioCodec != null -> {
|
||||
val profiles = HashSet<String>()
|
||||
for (profileLevel in codecCapabilities.profileLevels) {
|
||||
getAudioProfile(audioCodec, profileLevel.profile)?.let(profiles::add)
|
||||
}
|
||||
|
||||
Audio(
|
||||
name = audioCodec,
|
||||
mimeType = mimeType,
|
||||
profiles = profiles,
|
||||
maxBitrate = codecCapabilities.audioCapabilities.bitrateRange.upper,
|
||||
maxChannels = codecCapabilities.audioCapabilities.maxInputChannelCount,
|
||||
maxSampleRate = codecCapabilities.audioCapabilities.supportedSampleRateRanges
|
||||
.maxOfOrNull(Range<Int>::getUpper),
|
||||
)
|
||||
}
|
||||
else -> return null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
package org.jellyfin.mobile.player.deviceprofile
|
||||
|
||||
import android.media.MediaCodecList
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.sdk.model.api.CodecProfile
|
||||
import org.jellyfin.sdk.model.api.ContainerProfile
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DirectPlayProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleProfile
|
||||
import org.jellyfin.sdk.model.api.TranscodingProfile
|
||||
|
||||
class DeviceProfileBuilder(
|
||||
private val appPreferences: AppPreferences,
|
||||
) {
|
||||
private val supportedVideoCodecs: Array<Array<String>>
|
||||
private val supportedAudioCodecs: Array<Array<String>>
|
||||
|
||||
private val transcodingProfiles: List<TranscodingProfile>
|
||||
|
||||
init {
|
||||
require(
|
||||
SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_VIDEO_CODECS.size && SUPPORTED_CONTAINER_FORMATS.size == AVAILABLE_AUDIO_CODECS.size,
|
||||
)
|
||||
|
||||
// Load Android-supported codecs
|
||||
val videoCodecs: MutableMap<String, DeviceCodec.Video> = HashMap()
|
||||
val audioCodecs: MutableMap<String, DeviceCodec.Audio> = HashMap()
|
||||
val androidCodecs = MediaCodecList(MediaCodecList.REGULAR_CODECS)
|
||||
for (codecInfo in androidCodecs.codecInfos) {
|
||||
if (codecInfo.isEncoder) continue
|
||||
|
||||
for (mimeType in codecInfo.supportedTypes) {
|
||||
val codec = DeviceCodec.from(codecInfo.getCapabilitiesForType(mimeType)) ?: continue
|
||||
val name = codec.name
|
||||
when (codec) {
|
||||
is DeviceCodec.Video -> {
|
||||
if (videoCodecs.containsKey(name)) {
|
||||
videoCodecs[name] = videoCodecs[name]!!.mergeCodec(codec)
|
||||
} else {
|
||||
videoCodecs[name] = codec
|
||||
}
|
||||
}
|
||||
is DeviceCodec.Audio -> {
|
||||
if (audioCodecs.containsKey(mimeType)) {
|
||||
audioCodecs[name] = audioCodecs[name]!!.mergeCodec(codec)
|
||||
} else {
|
||||
audioCodecs[name] = codec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build map of supported codecs from device support and hardcoded data
|
||||
supportedVideoCodecs = Array(AVAILABLE_VIDEO_CODECS.size) { i ->
|
||||
AVAILABLE_VIDEO_CODECS[i].filter { codec ->
|
||||
videoCodecs.containsKey(codec)
|
||||
}.toTypedArray()
|
||||
}
|
||||
supportedAudioCodecs = Array(AVAILABLE_AUDIO_CODECS.size) { i ->
|
||||
AVAILABLE_AUDIO_CODECS[i].filter { codec ->
|
||||
audioCodecs.containsKey(codec) || codec in FORCED_AUDIO_CODECS
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "ts",
|
||||
videoCodec = "h264",
|
||||
audioCodec = "mp1,mp2,mp3,aac,ac3,eac3,dts,mlp,truehd",
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = "mkv",
|
||||
videoCodec = "h264",
|
||||
audioCodec = AVAILABLE_AUDIO_CODECS[SUPPORTED_CONTAINER_FORMATS.indexOf("mkv")].joinToString(","),
|
||||
protocol = MediaStreamProtocol.HLS,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
TranscodingProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = "mp3",
|
||||
videoCodec = "",
|
||||
audioCodec = "mp3",
|
||||
protocol = MediaStreamProtocol.HTTP,
|
||||
conditions = emptyList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun getDeviceProfile(): DeviceProfile {
|
||||
val containerProfiles = ArrayList<ContainerProfile>()
|
||||
val directPlayProfiles = ArrayList<DirectPlayProfile>()
|
||||
val codecProfiles = ArrayList<CodecProfile>()
|
||||
|
||||
for (i in SUPPORTED_CONTAINER_FORMATS.indices) {
|
||||
val container = SUPPORTED_CONTAINER_FORMATS[i]
|
||||
if (supportedVideoCodecs[i].isNotEmpty()) {
|
||||
containerProfiles.add(
|
||||
ContainerProfile(type = DlnaProfileType.VIDEO, container = container, conditions = emptyList()),
|
||||
)
|
||||
directPlayProfiles.add(
|
||||
DirectPlayProfile(
|
||||
type = DlnaProfileType.VIDEO,
|
||||
container = SUPPORTED_CONTAINER_FORMATS[i],
|
||||
videoCodec = supportedVideoCodecs[i].joinToString(","),
|
||||
audioCodec = supportedAudioCodecs[i].joinToString(","),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (supportedAudioCodecs[i].isNotEmpty()) {
|
||||
containerProfiles.add(
|
||||
ContainerProfile(type = DlnaProfileType.AUDIO, container = container, conditions = emptyList()),
|
||||
)
|
||||
directPlayProfiles.add(
|
||||
DirectPlayProfile(
|
||||
type = DlnaProfileType.AUDIO,
|
||||
container = SUPPORTED_CONTAINER_FORMATS[i],
|
||||
audioCodec = supportedAudioCodecs[i].joinToString(","),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val subtitleProfiles = when {
|
||||
appPreferences.exoPlayerDirectPlayAss -> {
|
||||
getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES + SUBTITLES_SSA, EXO_EXTERNAL_SUBTITLES + SUBTITLES_SSA)
|
||||
}
|
||||
else -> getSubtitleProfiles(EXO_EMBEDDED_SUBTITLES, EXO_EXTERNAL_SUBTITLES)
|
||||
}
|
||||
|
||||
return DeviceProfile(
|
||||
name = Constants.APP_INFO_NAME,
|
||||
directPlayProfiles = directPlayProfiles,
|
||||
transcodingProfiles = transcodingProfiles,
|
||||
containerProfiles = containerProfiles,
|
||||
codecProfiles = codecProfiles,
|
||||
subtitleProfiles = subtitleProfiles,
|
||||
maxStreamingBitrate = MAX_STREAMING_BITRATE,
|
||||
maxStaticBitrate = MAX_STATIC_BITRATE,
|
||||
musicStreamingTranscodingBitrate = MAX_MUSIC_TRANSCODING_BITRATE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun getSubtitleProfiles(embedded: Array<String>, external: Array<String>): List<SubtitleProfile> = ArrayList<SubtitleProfile>().apply {
|
||||
for (format in embedded) {
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED))
|
||||
}
|
||||
for (format in external) {
|
||||
add(SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL))
|
||||
}
|
||||
}
|
||||
|
||||
fun getExternalPlayerProfile(): DeviceProfile = DeviceProfile(
|
||||
name = EXTERNAL_PLAYER_PROFILE_NAME,
|
||||
directPlayProfiles = listOf(
|
||||
DirectPlayProfile(type = DlnaProfileType.VIDEO, container = ""),
|
||||
DirectPlayProfile(type = DlnaProfileType.AUDIO, container = ""),
|
||||
),
|
||||
transcodingProfiles = emptyList(),
|
||||
containerProfiles = emptyList(),
|
||||
codecProfiles = emptyList(),
|
||||
subtitleProfiles = buildList {
|
||||
EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format ->
|
||||
SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EMBED)
|
||||
}
|
||||
EXTERNAL_PLAYER_SUBTITLES.mapTo(this) { format ->
|
||||
SubtitleProfile(format = format, method = SubtitleDeliveryMethod.EXTERNAL)
|
||||
}
|
||||
},
|
||||
maxStreamingBitrate = Int.MAX_VALUE,
|
||||
maxStaticBitrate = Int.MAX_VALUE,
|
||||
musicStreamingTranscodingBitrate = Int.MAX_VALUE,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val EXTERNAL_PLAYER_PROFILE_NAME = Constants.APP_INFO_NAME + " External Player"
|
||||
|
||||
/**
|
||||
* List of container formats supported by ExoPlayer
|
||||
*
|
||||
* IMPORTANT: Don't change without updating [AVAILABLE_VIDEO_CODECS] and [AVAILABLE_AUDIO_CODECS]
|
||||
*/
|
||||
private val SUPPORTED_CONTAINER_FORMATS = arrayOf(
|
||||
"mp4", "fmp4", "webm", "mkv", "mp3", "ogg", "wav", "mpegts", "flv", "aac", "flac", "3gp",
|
||||
)
|
||||
|
||||
/**
|
||||
* IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS],
|
||||
* as it maps the codecs to the containers with the same index!
|
||||
*/
|
||||
private val AVAILABLE_VIDEO_CODECS = arrayOf(
|
||||
// mp4
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
// fmp4
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp9"),
|
||||
// webm
|
||||
arrayOf("vp8", "vp9", "av1"),
|
||||
// mkv
|
||||
arrayOf("mpeg1video", "mpeg2video", "h263", "mpeg4", "h264", "hevc", "av1", "vp8", "vp9", "av1"),
|
||||
// mp3
|
||||
emptyArray(),
|
||||
// ogg
|
||||
emptyArray(),
|
||||
// wav
|
||||
emptyArray(),
|
||||
// mpegts
|
||||
arrayOf("mpeg1video", "mpeg2video", "mpeg4", "h264", "hevc"),
|
||||
// flv
|
||||
arrayOf("mpeg4", "h264"),
|
||||
// aac
|
||||
emptyArray(),
|
||||
// flac
|
||||
emptyArray(),
|
||||
// 3gp
|
||||
arrayOf("h263", "mpeg4", "h264", "hevc"),
|
||||
)
|
||||
|
||||
/**
|
||||
* List of PCM codecs supported by ExoPlayer by default
|
||||
*/
|
||||
private val PCM_CODECS = arrayOf(
|
||||
"pcm_s8",
|
||||
"pcm_s16be",
|
||||
"pcm_s16le",
|
||||
"pcm_s24le",
|
||||
"pcm_s32le",
|
||||
"pcm_f32le",
|
||||
"pcm_alaw",
|
||||
"pcm_mulaw",
|
||||
)
|
||||
|
||||
/**
|
||||
* IMPORTANT: Must have same length as [SUPPORTED_CONTAINER_FORMATS],
|
||||
* as it maps the codecs to the containers with the same index!
|
||||
*/
|
||||
private val AVAILABLE_AUDIO_CODECS = arrayOf(
|
||||
// mp4
|
||||
arrayOf("mp1", "mp2", "mp3", "aac", "alac", "ac3"),
|
||||
// fmp4
|
||||
arrayOf("mp3", "aac", "ac3", "eac3"),
|
||||
// webm
|
||||
arrayOf("vorbis", "opus"),
|
||||
// mkv
|
||||
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "vorbis", "opus", "flac", "alac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
// mp3
|
||||
arrayOf("mp3"),
|
||||
// ogg
|
||||
arrayOf("vorbis", "opus", "flac"),
|
||||
// wav
|
||||
PCM_CODECS,
|
||||
// mpegts
|
||||
arrayOf(*PCM_CODECS, "mp1", "mp2", "mp3", "aac", "ac3", "eac3", "dts", "mlp", "truehd"),
|
||||
// flv
|
||||
arrayOf("mp3", "aac"),
|
||||
// aac
|
||||
arrayOf("aac"),
|
||||
// flac
|
||||
arrayOf("flac"),
|
||||
// 3gp
|
||||
arrayOf("3gpp", "aac", "flac"),
|
||||
)
|
||||
|
||||
/**
|
||||
* List of audio codecs that will be added to the device profile regardless of [MediaCodecList] advertising them.
|
||||
* This is especially useful for codecs supported by decoders integrated to ExoPlayer or added through an extension.
|
||||
*/
|
||||
private val FORCED_AUDIO_CODECS = arrayOf(*PCM_CODECS, "alac", "aac", "ac3", "eac3", "dts", "mlp", "truehd")
|
||||
|
||||
private val EXO_EMBEDDED_SUBTITLES = arrayOf("dvbsub", "pgssub", "srt", "subrip", "ttml")
|
||||
private val EXO_EXTERNAL_SUBTITLES = arrayOf("srt", "subrip", "ttml", "vtt", "webvtt")
|
||||
private val SUBTITLES_SSA = arrayOf("ssa", "ass")
|
||||
private val EXTERNAL_PLAYER_SUBTITLES = arrayOf("ass", "dvbsub", "pgssub", "srt", "srt", "ssa", "subrip", "subrip", "ttml", "ttml", "vtt", "webvtt")
|
||||
|
||||
/**
|
||||
* Taken from Jellyfin Web:
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L276
|
||||
*/
|
||||
private const val MAX_STREAMING_BITRATE = 120000000
|
||||
|
||||
/**
|
||||
* Taken from Jellyfin Web:
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L372
|
||||
*/
|
||||
private const val MAX_STATIC_BITRATE = 100000000
|
||||
|
||||
/**
|
||||
* Taken from Jellyfin Web:
|
||||
* https://github.com/jellyfin/jellyfin-web/blob/de690740f03c0568ba3061c4c586bd78b375d882/src/scripts/browserDeviceProfile.js#L373
|
||||
*/
|
||||
private const val MAX_MUSIC_TRANSCODING_BITRATE = 384000
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.jellyfin.mobile.utils.extensions.size
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
@Parcelize
|
||||
data class PlayOptions(
|
||||
val ids: List<UUID>,
|
||||
val mediaSourceId: String?,
|
||||
val startIndex: Int,
|
||||
val startPositionTicks: Long?,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun fromJson(json: JSONObject): PlayOptions? = try {
|
||||
PlayOptions(
|
||||
ids = json.optJSONArray("ids")?.let { array ->
|
||||
ArrayList<UUID>().apply {
|
||||
for (i in 0 until array.size) {
|
||||
array.getString(i).toUUIDOrNull()?.let(this::add)
|
||||
}
|
||||
}
|
||||
} ?: emptyList(),
|
||||
mediaSourceId = json.optString("mediaSourceId"),
|
||||
startIndex = json.optInt("startIndex"),
|
||||
startPositionTicks = json.optLong("startPositionTicks").takeIf { it > 0 },
|
||||
audioStreamIndex = json.optString("audioStreamIndex").toIntOrNull(),
|
||||
subtitleStreamIndex = json.optString("subtitleStreamIndex").toIntOrNull(),
|
||||
)
|
||||
} catch (e: JSONException) {
|
||||
Timber.e(e, "Failed to parse playback options: %s", json)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
sealed class PlayerEvent {
|
||||
object Pause : PlayerEvent()
|
||||
object Resume : PlayerEvent()
|
||||
object Stop : PlayerEvent()
|
||||
object Destroy : PlayerEvent()
|
||||
data class Seek(val ms: Long) : PlayerEvent()
|
||||
data class SetVolume(val volume: Int) : PlayerEvent()
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
|
||||
class PlayerLifecycleObserver(private val viewModel: PlayerViewModel) : DefaultLifecycleObserver {
|
||||
|
||||
override fun onCreate(owner: LifecycleOwner) {
|
||||
viewModel.setupPlayer()
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (!viewModel.notificationHelper.allowBackgroundAudio) {
|
||||
viewModel.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.media.session.MediaSession
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
|
||||
@SuppressLint("MissingOnPlayFromSearch")
|
||||
class PlayerMediaSessionCallback(private val viewModel: PlayerViewModel) : MediaSession.Callback() {
|
||||
override fun onPlay() {
|
||||
viewModel.play()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
viewModel.pause()
|
||||
}
|
||||
|
||||
override fun onSeekTo(pos: Long) {
|
||||
viewModel.playerOrNull?.seekTo(pos)
|
||||
}
|
||||
|
||||
override fun onRewind() {
|
||||
viewModel.rewind()
|
||||
}
|
||||
|
||||
override fun onFastForward() {
|
||||
viewModel.fastForward()
|
||||
}
|
||||
|
||||
override fun onSkipToPrevious() {
|
||||
viewModel.skipToPrevious()
|
||||
}
|
||||
|
||||
override fun onSkipToNext() {
|
||||
viewModel.skipToNext()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
viewModel.stop()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
|
||||
enum class PlayerNotificationAction(
|
||||
val action: String,
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val label: Int,
|
||||
) {
|
||||
PLAY(
|
||||
Constants.ACTION_PLAY,
|
||||
R.drawable.ic_play_black_42dp,
|
||||
R.string.notification_action_play,
|
||||
),
|
||||
PAUSE(
|
||||
Constants.ACTION_PAUSE,
|
||||
R.drawable.ic_pause_black_42dp,
|
||||
R.string.notification_action_pause,
|
||||
),
|
||||
REWIND(
|
||||
Constants.ACTION_REWIND,
|
||||
R.drawable.ic_rewind_black_32dp,
|
||||
R.string.notification_action_rewind,
|
||||
),
|
||||
FAST_FORWARD(
|
||||
Constants.ACTION_FAST_FORWARD,
|
||||
R.drawable.ic_fast_forward_black_32dp,
|
||||
R.string.notification_action_fast_forward,
|
||||
),
|
||||
PREVIOUS(
|
||||
Constants.ACTION_PREVIOUS,
|
||||
R.drawable.ic_skip_previous_black_32dp,
|
||||
R.string.notification_action_previous,
|
||||
),
|
||||
NEXT(
|
||||
Constants.ACTION_NEXT,
|
||||
R.drawable.ic_skip_next_black_32dp,
|
||||
R.string.notification_action_next,
|
||||
),
|
||||
STOP(
|
||||
Constants.ACTION_STOP,
|
||||
0,
|
||||
R.string.notification_action_stop,
|
||||
),
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
package org.jellyfin.mobile.player.interaction
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.Bitmap
|
||||
import android.media.MediaMetadata
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import coil.ImageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.google.android.exoplayer2.Player
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
import org.jellyfin.mobile.MainActivity
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.Constants.VIDEO_PLAYER_NOTIFICATION_ID
|
||||
import org.jellyfin.mobile.utils.createMediaNotificationChannel
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.api.operations.ImageApi
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent {
|
||||
private val context: Context = viewModel.getApplication()
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val notificationManager: NotificationManager? by lazy { context.getSystemService() }
|
||||
private val imageApi: ImageApi = get<ApiClient>().imageApi
|
||||
private val imageLoader: ImageLoader by inject()
|
||||
private val receiverRegistered = AtomicBoolean(false)
|
||||
|
||||
val allowBackgroundAudio: Boolean
|
||||
get() = appPreferences.exoPlayerAllowBackgroundAudio
|
||||
|
||||
private val notificationActionReceiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Constants.ACTION_PLAY -> viewModel.play()
|
||||
Constants.ACTION_PAUSE -> viewModel.pause()
|
||||
Constants.ACTION_REWIND -> viewModel.rewind()
|
||||
Constants.ACTION_FAST_FORWARD -> viewModel.fastForward()
|
||||
Constants.ACTION_PREVIOUS -> viewModel.skipToPrevious()
|
||||
Constants.ACTION_NEXT -> viewModel.skipToNext()
|
||||
Constants.ACTION_STOP -> viewModel.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION", "LongMethod", "CyclomaticComplexMethod")
|
||||
fun postNotification() {
|
||||
val nm = notificationManager ?: return
|
||||
val player = viewModel.playerOrNull ?: return
|
||||
val currentMediaSource = viewModel.queueManager.currentMediaSourceOrNull ?: return
|
||||
val hasPrevious = viewModel.queueManager.hasPrevious()
|
||||
val hasNext = viewModel.queueManager.hasNext()
|
||||
val playbackState = player.playbackState
|
||||
if (playbackState != Player.STATE_READY && playbackState != Player.STATE_BUFFERING) return
|
||||
|
||||
// Create notification channel
|
||||
context.createMediaNotificationChannel(nm)
|
||||
|
||||
viewModel.viewModelScope.launch {
|
||||
val mediaIcon: Bitmap? = withContext(Dispatchers.IO) {
|
||||
loadImage(currentMediaSource)
|
||||
}
|
||||
|
||||
val style = Notification.MediaStyle().apply {
|
||||
setMediaSession(viewModel.mediaSession.sessionToken)
|
||||
setShowActionsInCompactView(0, 1, 2)
|
||||
}
|
||||
|
||||
val notification = Notification.Builder(context).apply {
|
||||
if (AndroidVersion.isAtLeastO) {
|
||||
// Set notification channel on Android O and above
|
||||
setChannelId(Constants.MEDIA_NOTIFICATION_CHANNEL_ID)
|
||||
setColorized(true)
|
||||
} else {
|
||||
setPriority(Notification.PRIORITY_LOW)
|
||||
}
|
||||
setSmallIcon(R.drawable.ic_notification)
|
||||
mediaIcon?.let(::setLargeIcon)
|
||||
setContentTitle(currentMediaSource.name)
|
||||
currentMediaSource.item?.artists?.joinToString()?.let(::setContentText)
|
||||
setStyle(style)
|
||||
setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
when {
|
||||
hasPrevious -> addAction(generateAction(PlayerNotificationAction.PREVIOUS))
|
||||
else -> addAction(generateAction(PlayerNotificationAction.REWIND))
|
||||
}
|
||||
val playbackAction = when {
|
||||
!player.playWhenReady -> PlayerNotificationAction.PLAY
|
||||
else -> PlayerNotificationAction.PAUSE
|
||||
}
|
||||
addAction(generateAction(playbackAction))
|
||||
when {
|
||||
hasNext -> addAction(generateAction(PlayerNotificationAction.NEXT))
|
||||
else -> addAction(generateAction(PlayerNotificationAction.FAST_FORWARD))
|
||||
}
|
||||
setContentIntent(buildContentIntent())
|
||||
setDeleteIntent(buildDeleteIntent())
|
||||
|
||||
// prevents the notification from being dismissed while playback is ongoing
|
||||
setOngoing(player.isPlaying)
|
||||
}.build()
|
||||
|
||||
nm.notify(VIDEO_PLAYER_NOTIFICATION_ID, notification)
|
||||
|
||||
mediaIcon?.let {
|
||||
viewModel.mediaSession.controller.metadata?.let {
|
||||
if (!it.containsKey(MediaMetadata.METADATA_KEY_ART)) {
|
||||
viewModel.mediaSession.setMetadata(
|
||||
MediaMetadata.Builder(it)
|
||||
.putBitmap(MediaMetadata.METADATA_KEY_ART, mediaIcon)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (receiverRegistered.compareAndSet(false, true)) {
|
||||
val filter = IntentFilter()
|
||||
for (notificationAction in PlayerNotificationAction.values()) {
|
||||
filter.addAction(notificationAction.action)
|
||||
}
|
||||
ContextCompat.registerReceiver(
|
||||
context,
|
||||
notificationActionReceiver,
|
||||
filter,
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissNotification() {
|
||||
notificationManager?.cancel(VIDEO_PLAYER_NOTIFICATION_ID)
|
||||
if (receiverRegistered.compareAndSet(true, false)) {
|
||||
context.unregisterReceiver(notificationActionReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? {
|
||||
val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height)
|
||||
|
||||
val imageUrl = imageApi.getItemImageUrl(
|
||||
itemId = mediaSource.itemId,
|
||||
imageType = ImageType.PRIMARY,
|
||||
maxWidth = size,
|
||||
maxHeight = size,
|
||||
)
|
||||
val imageRequest = ImageRequest.Builder(context).data(imageUrl).build()
|
||||
return imageLoader.execute(imageRequest).drawable?.toBitmap()
|
||||
}
|
||||
|
||||
private fun generateAction(playerNotificationAction: PlayerNotificationAction): Notification.Action {
|
||||
val intent = Intent(playerNotificationAction.action).apply {
|
||||
`package` = BuildConfig.APPLICATION_ID
|
||||
}
|
||||
val pendingIntent = PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
|
||||
@Suppress("DEPRECATION")
|
||||
return Notification.Action.Builder(
|
||||
playerNotificationAction.icon,
|
||||
context.getString(playerNotificationAction.label),
|
||||
pendingIntent,
|
||||
).build()
|
||||
}
|
||||
|
||||
private fun buildContentIntent(): PendingIntent {
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
|
||||
}
|
||||
return PendingIntent.getActivity(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
|
||||
}
|
||||
|
||||
private fun buildDeleteIntent(): PendingIntent {
|
||||
val intent = Intent(Constants.ACTION_STOP).apply {
|
||||
`package` = BuildConfig.APPLICATION_ID
|
||||
}
|
||||
return PendingIntent.getBroadcast(context, 0, intent, Constants.PENDING_INTENT_FLAGS)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.player.qualityoptions
|
||||
|
||||
data class QualityOption(
|
||||
val maxHeight: Int,
|
||||
val bitrate: Int,
|
||||
)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.jellyfin.mobile.player.qualityoptions
|
||||
|
||||
import android.util.Rational
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
|
||||
class QualityOptionsProvider {
|
||||
|
||||
private val defaultQualityOptions = listOf(
|
||||
QualityOption(maxHeight = 2160, bitrate = 120000000),
|
||||
QualityOption(maxHeight = 2160, bitrate = 80000000),
|
||||
QualityOption(maxHeight = 1080, bitrate = 60000000),
|
||||
QualityOption(maxHeight = 1080, bitrate = 40000000),
|
||||
QualityOption(maxHeight = 1080, bitrate = 20000000),
|
||||
QualityOption(maxHeight = 1080, bitrate = 15000000),
|
||||
QualityOption(maxHeight = 1080, bitrate = 10000000),
|
||||
QualityOption(maxHeight = 720, bitrate = 8000000),
|
||||
QualityOption(maxHeight = 720, bitrate = 6000000),
|
||||
QualityOption(maxHeight = 720, bitrate = 4000000),
|
||||
QualityOption(maxHeight = 480, bitrate = 3000000),
|
||||
QualityOption(maxHeight = 480, bitrate = 1500000),
|
||||
QualityOption(maxHeight = 480, bitrate = 720000),
|
||||
QualityOption(maxHeight = 360, bitrate = 420000),
|
||||
QualityOption(maxHeight = 0, bitrate = 0), // auto
|
||||
)
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
fun getApplicableQualityOptions(videoWidth: Int, videoHeight: Int): List<QualityOption> {
|
||||
// If the aspect ratio is less than 16/9, set the width as if it were pillarboxed
|
||||
// i.e. 4:3 1440x1080 -> 1920x1080
|
||||
val maxAllowedWidth = when {
|
||||
Rational(videoWidth, videoHeight) < Constants.ASPECT_RATIO_16_9 -> videoHeight * 16 / 9
|
||||
else -> videoWidth
|
||||
}
|
||||
|
||||
val maxAllowedHeight = when {
|
||||
maxAllowedWidth >= 3800 -> 2160
|
||||
// Some 1080p videos are apparently reported as 1912
|
||||
maxAllowedWidth >= 1900 -> 1080
|
||||
maxAllowedWidth >= 1260 -> 720
|
||||
maxAllowedWidth >= 620 -> 480
|
||||
else -> 360
|
||||
}
|
||||
|
||||
return defaultQualityOptions.takeLastWhile { option -> option.maxHeight <= maxAllowedHeight }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,330 @@
|
|||
package org.jellyfin.mobile.player.queue
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.source.MediaSource
|
||||
import com.google.android.exoplayer2.source.MergingMediaSource
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
||||
import com.google.android.exoplayer2.source.SingleSampleMediaSource
|
||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||
import org.jellyfin.mobile.player.PlayerException
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.player.source.ExternalSubtitleStream
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.player.source.MediaSourceResolver
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.videosApi
|
||||
import org.jellyfin.sdk.api.operations.VideosApi
|
||||
import org.jellyfin.sdk.model.api.MediaProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import java.util.UUID
|
||||
|
||||
class QueueManager(
|
||||
private val viewModel: PlayerViewModel,
|
||||
) : KoinComponent {
|
||||
private val apiClient: ApiClient = get()
|
||||
private val videosApi: VideosApi = apiClient.videosApi
|
||||
private val mediaSourceResolver: MediaSourceResolver by inject()
|
||||
private val deviceProfileBuilder: DeviceProfileBuilder by inject()
|
||||
private val deviceProfile = deviceProfileBuilder.getDeviceProfile()
|
||||
|
||||
private var currentQueue: List<UUID> = emptyList()
|
||||
private var currentQueueIndex: Int = 0
|
||||
|
||||
private val _currentMediaSource: MutableLiveData<JellyfinMediaSource> = MutableLiveData()
|
||||
val currentMediaSource: LiveData<JellyfinMediaSource>
|
||||
get() = _currentMediaSource
|
||||
|
||||
inline val currentMediaSourceOrNull: JellyfinMediaSource?
|
||||
get() = currentMediaSource.value
|
||||
|
||||
/**
|
||||
* Handle initial playback options from fragment.
|
||||
* Start of a playback session that can contain one or multiple played videos.
|
||||
*
|
||||
* @return an error of type [PlayerException] or null on success.
|
||||
*/
|
||||
suspend fun initializePlaybackQueue(playOptions: PlayOptions): PlayerException? {
|
||||
currentQueue = playOptions.ids
|
||||
currentQueueIndex = playOptions.startIndex
|
||||
|
||||
val itemId = when {
|
||||
currentQueue.isNotEmpty() -> currentQueue[currentQueueIndex]
|
||||
else -> playOptions.mediaSourceId?.toUUIDOrNull()
|
||||
} ?: return PlayerException.InvalidPlayOptions()
|
||||
|
||||
startPlayback(
|
||||
itemId = itemId,
|
||||
mediaSourceId = playOptions.mediaSourceId,
|
||||
maxStreamingBitrate = null,
|
||||
startTimeTicks = playOptions.startPositionTicks,
|
||||
audioStreamIndex = playOptions.audioStreamIndex,
|
||||
subtitleStreamIndex = playOptions.subtitleStreamIndex,
|
||||
playWhenReady = true,
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a specific media item specified by [itemId] and [mediaSourceId].
|
||||
*
|
||||
* @return an error of type [PlayerException] or null on success.
|
||||
*/
|
||||
private suspend fun startPlayback(
|
||||
itemId: UUID,
|
||||
mediaSourceId: String?,
|
||||
maxStreamingBitrate: Int?,
|
||||
startTimeTicks: Long? = null,
|
||||
audioStreamIndex: Int? = null,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
playWhenReady: Boolean = true,
|
||||
): PlayerException? {
|
||||
mediaSourceResolver.resolveMediaSource(
|
||||
itemId = itemId,
|
||||
mediaSourceId = mediaSourceId,
|
||||
deviceProfile = deviceProfile,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
startTimeTicks = startTimeTicks,
|
||||
audioStreamIndex = audioStreamIndex,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
).onSuccess { jellyfinMediaSource ->
|
||||
// Ensure transcoding of the current element is stopped
|
||||
currentMediaSourceOrNull?.let { oldMediaSource ->
|
||||
viewModel.stopTranscoding(oldMediaSource)
|
||||
}
|
||||
|
||||
_currentMediaSource.value = jellyfinMediaSource
|
||||
|
||||
// Load new media source
|
||||
viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady)
|
||||
}.onFailure { error ->
|
||||
// Should always be of this type, other errors are silently dropped
|
||||
return error as? PlayerException
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinitialize current media source without changing settings
|
||||
*/
|
||||
fun tryRestartPlayback() {
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return
|
||||
|
||||
viewModel.load(currentMediaSource, prepareStreams(currentMediaSource), playWhenReady = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the maximum bitrate to the specified value.
|
||||
*/
|
||||
suspend fun changeBitrate(bitrate: Int?): Boolean {
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
// Bitrate didn't change, ignore
|
||||
if (currentMediaSource.maxStreamingBitrate == bitrate) return true
|
||||
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
return startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = bitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
|
||||
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
) == null
|
||||
}
|
||||
|
||||
fun hasPrevious(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex > 0
|
||||
|
||||
fun hasNext(): Boolean = currentQueue.isNotEmpty() && currentQueueIndex < currentQueue.lastIndex
|
||||
|
||||
suspend fun previous(): Boolean {
|
||||
if (!hasPrevious()) return false
|
||||
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentQueue[--currentQueueIndex],
|
||||
mediaSourceId = null,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun next(): Boolean {
|
||||
if (!hasNext()) return false
|
||||
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentQueue[++currentQueueIndex],
|
||||
mediaSourceId = null,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the [MediaSource] to be played by ExoPlayer.
|
||||
*
|
||||
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
|
||||
* @return A [MediaSource]. This can be the media stream of the correct type for the playback method or
|
||||
* a [MergingMediaSource] containing the mentioned media stream and all external subtitle streams.
|
||||
*/
|
||||
@CheckResult
|
||||
private fun prepareStreams(source: JellyfinMediaSource): MediaSource {
|
||||
val videoSource = createVideoMediaSource(source)
|
||||
val subtitleSources = createExternalSubtitleMediaSources(source)
|
||||
return when {
|
||||
subtitleSources.isNotEmpty() -> MergingMediaSource(videoSource, *subtitleSources)
|
||||
else -> videoSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the [MediaSource] for the main media stream (video/audio/embedded subs).
|
||||
*
|
||||
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
|
||||
* @return A [MediaSource]. The type of MediaSource depends on the playback method/protocol.
|
||||
*/
|
||||
@CheckResult
|
||||
private fun createVideoMediaSource(source: JellyfinMediaSource): MediaSource {
|
||||
val sourceInfo = source.sourceInfo
|
||||
val (url, factory) = when (source.playMethod) {
|
||||
PlayMethod.DIRECT_PLAY -> {
|
||||
when (sourceInfo.protocol) {
|
||||
MediaProtocol.FILE -> {
|
||||
val url = videosApi.getVideoStreamUrl(
|
||||
itemId = source.itemId,
|
||||
static = true,
|
||||
playSessionId = source.playSessionId,
|
||||
mediaSourceId = source.id,
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
)
|
||||
|
||||
url to get<ProgressiveMediaSource.Factory>()
|
||||
}
|
||||
MediaProtocol.HTTP -> {
|
||||
val url = requireNotNull(sourceInfo.path)
|
||||
val factory = get<HlsMediaSource.Factory>().setAllowChunklessPreparation(true)
|
||||
|
||||
url to factory
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unsupported protocol ${sourceInfo.protocol}")
|
||||
}
|
||||
}
|
||||
PlayMethod.DIRECT_STREAM -> {
|
||||
val container = requireNotNull(sourceInfo.container) { "Missing direct stream container" }
|
||||
val url = videosApi.getVideoStreamByContainerUrl(
|
||||
itemId = source.itemId,
|
||||
container = container,
|
||||
playSessionId = source.playSessionId,
|
||||
mediaSourceId = source.id,
|
||||
deviceId = apiClient.deviceInfo.id,
|
||||
)
|
||||
|
||||
url to get<ProgressiveMediaSource.Factory>()
|
||||
}
|
||||
PlayMethod.TRANSCODE -> {
|
||||
val transcodingPath = requireNotNull(sourceInfo.transcodingUrl) { "Missing transcode URL" }
|
||||
val protocol = sourceInfo.transcodingSubProtocol
|
||||
require(protocol == MediaStreamProtocol.HLS) { "Unsupported transcode protocol '$protocol'" }
|
||||
val transcodingUrl = apiClient.createUrl(transcodingPath)
|
||||
val factory = get<HlsMediaSource.Factory>().setAllowChunklessPreparation(true)
|
||||
|
||||
transcodingUrl to factory
|
||||
}
|
||||
}
|
||||
|
||||
val mediaItem = MediaItem.Builder()
|
||||
.setMediaId(source.itemId.toString())
|
||||
.setUri(url)
|
||||
.build()
|
||||
|
||||
return factory.createMediaSource(mediaItem)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates [MediaSource]s for all external subtitle streams in the [JellyfinMediaSource].
|
||||
*
|
||||
* @param source The [JellyfinMediaSource] object containing all necessary info about the item to be played.
|
||||
* @return The parsed MediaSources for the subtitles.
|
||||
*/
|
||||
@CheckResult
|
||||
private fun createExternalSubtitleMediaSources(
|
||||
source: JellyfinMediaSource,
|
||||
): Array<MediaSource> {
|
||||
val factory = get<SingleSampleMediaSource.Factory>()
|
||||
return source.externalSubtitleStreams.map { stream ->
|
||||
val uri = Uri.parse(apiClient.createUrl(stream.deliveryUrl))
|
||||
val mediaItem = MediaItem.SubtitleConfiguration.Builder(uri).apply {
|
||||
setId("${ExternalSubtitleStream.ID_PREFIX}${stream.index}")
|
||||
setLabel(stream.displayTitle)
|
||||
setMimeType(stream.mimeType)
|
||||
setLanguage(stream.language)
|
||||
}.build()
|
||||
factory.createMediaSource(mediaItem, source.runTimeMs)
|
||||
}.toTypedArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the specified [audio stream][stream] and restart playback, for example while transcoding.
|
||||
*
|
||||
* @return true if playback was restarted with the new selection.
|
||||
*/
|
||||
suspend fun selectAudioStreamAndRestartPlayback(stream: MediaStream): Boolean {
|
||||
require(stream.type == MediaStreamType.AUDIO)
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = stream.index,
|
||||
subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex,
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to the specified [subtitle stream][stream] and restart playback,
|
||||
* for example because the selected subtitle has to be encoded into the video.
|
||||
*
|
||||
* @param stream The subtitle stream to select, or null to disable subtitles.
|
||||
* @return true if playback was restarted with the new selection.
|
||||
*/
|
||||
suspend fun selectSubtitleStreamAndRestartPlayback(stream: MediaStream?): Boolean {
|
||||
require(stream == null || stream.type == MediaStreamType.SUBTITLE)
|
||||
val currentMediaSource = currentMediaSourceOrNull ?: return false
|
||||
val currentPlayState = viewModel.getStateAndPause() ?: return false
|
||||
|
||||
startPlayback(
|
||||
itemId = currentMediaSource.itemId,
|
||||
mediaSourceId = currentMediaSource.id,
|
||||
maxStreamingBitrate = currentMediaSource.maxStreamingBitrate,
|
||||
startTimeTicks = currentPlayState.position * Constants.TICKS_PER_MILLISECOND,
|
||||
audioStreamIndex = currentMediaSource.selectedAudioStreamIndex,
|
||||
subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle
|
||||
playWhenReady = currentPlayState.playWhenReady,
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package org.jellyfin.mobile.player.source
|
||||
|
||||
data class ExternalSubtitleStream(
|
||||
val index: Int,
|
||||
val deliveryUrl: String,
|
||||
val mimeType: String,
|
||||
val displayTitle: String,
|
||||
val language: String,
|
||||
) {
|
||||
companion object {
|
||||
const val ID_PREFIX = "external:"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
package org.jellyfin.mobile.player.source
|
||||
|
||||
import org.jellyfin.mobile.player.deviceprofile.CodecHelpers
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
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.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import java.util.UUID
|
||||
|
||||
class JellyfinMediaSource(
|
||||
val itemId: UUID,
|
||||
val item: BaseItemDto?,
|
||||
val sourceInfo: MediaSourceInfo,
|
||||
val playSessionId: String,
|
||||
val liveStreamId: String?,
|
||||
val maxStreamingBitrate: Int?,
|
||||
private var startTimeTicks: Long? = null,
|
||||
audioStreamIndex: Int? = null,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
) {
|
||||
val id: String = requireNotNull(sourceInfo.id) { "Media source has no id" }
|
||||
val name: String = item?.name ?: sourceInfo.name.orEmpty()
|
||||
|
||||
val playMethod: PlayMethod = when {
|
||||
sourceInfo.supportsDirectPlay -> PlayMethod.DIRECT_PLAY
|
||||
sourceInfo.supportsDirectStream -> PlayMethod.DIRECT_STREAM
|
||||
sourceInfo.supportsTranscoding -> PlayMethod.TRANSCODE
|
||||
else -> throw IllegalArgumentException("No play method found for $name ($itemId)")
|
||||
}
|
||||
|
||||
var startTimeMs: Long
|
||||
get() = (startTimeTicks ?: 0L) / Constants.TICKS_PER_MILLISECOND
|
||||
set(value) {
|
||||
startTimeTicks = value * Constants.TICKS_PER_MILLISECOND
|
||||
}
|
||||
val runTimeTicks: Long = sourceInfo.runTimeTicks ?: 0
|
||||
val runTimeMs: Long = runTimeTicks / Constants.TICKS_PER_MILLISECOND
|
||||
|
||||
val mediaStreams: List<MediaStream> = sourceInfo.mediaStreams.orEmpty()
|
||||
val audioStreams: List<MediaStream>
|
||||
val subtitleStreams: List<MediaStream>
|
||||
val externalSubtitleStreams: List<ExternalSubtitleStream>
|
||||
|
||||
var selectedVideoStream: MediaStream? = null
|
||||
private set
|
||||
var selectedAudioStream: MediaStream? = null
|
||||
private set
|
||||
var selectedSubtitleStream: MediaStream? = null
|
||||
private set
|
||||
|
||||
val selectedAudioStreamIndex: Int?
|
||||
get() = selectedAudioStream?.index
|
||||
val selectedSubtitleStreamIndex: Int
|
||||
// -1 disables subtitles, null would select the default subtitle
|
||||
// If the default should be played, it would be explicitly set above
|
||||
get() = selectedSubtitleStream?.index ?: -1
|
||||
|
||||
init {
|
||||
// Classify MediaStreams
|
||||
val audio = ArrayList<MediaStream>()
|
||||
val subtitles = ArrayList<MediaStream>()
|
||||
val externalSubtitles = ArrayList<ExternalSubtitleStream>()
|
||||
for (mediaStream in mediaStreams) {
|
||||
when (mediaStream.type) {
|
||||
MediaStreamType.VIDEO -> {
|
||||
// Always select the first available video stream
|
||||
if (selectedVideoStream == null) {
|
||||
selectedVideoStream = mediaStream
|
||||
}
|
||||
}
|
||||
MediaStreamType.AUDIO -> {
|
||||
audio += mediaStream
|
||||
if (mediaStream.index == (audioStreamIndex ?: sourceInfo.defaultAudioStreamIndex)) {
|
||||
selectedAudioStream = mediaStream
|
||||
}
|
||||
}
|
||||
MediaStreamType.SUBTITLE -> {
|
||||
subtitles += mediaStream
|
||||
if (mediaStream.index == (subtitleStreamIndex ?: sourceInfo.defaultSubtitleStreamIndex)) {
|
||||
selectedSubtitleStream = mediaStream
|
||||
}
|
||||
|
||||
// External subtitles as specified by the deliveryMethod.
|
||||
// It is set to external either for external subtitle files or when transcoding.
|
||||
// In the latter case, subtitles are extracted from the source file by the server.
|
||||
if (mediaStream.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL) {
|
||||
val deliveryUrl = mediaStream.deliveryUrl
|
||||
val mimeType = CodecHelpers.getSubtitleMimeType(mediaStream.codec)
|
||||
if (deliveryUrl != null && mimeType != null) {
|
||||
externalSubtitles += ExternalSubtitleStream(
|
||||
index = mediaStream.index,
|
||||
deliveryUrl = deliveryUrl,
|
||||
mimeType = mimeType,
|
||||
displayTitle = mediaStream.displayTitle.orEmpty(),
|
||||
language = mediaStream.language ?: Constants.LANGUAGE_UNDEFINED,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
MediaStreamType.EMBEDDED_IMAGE,
|
||||
MediaStreamType.DATA,
|
||||
MediaStreamType.LYRIC,
|
||||
-> Unit // ignore
|
||||
}
|
||||
}
|
||||
|
||||
audioStreams = audio
|
||||
subtitleStreams = subtitles
|
||||
externalSubtitleStreams = externalSubtitles
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the specified [audio stream][stream] in the source.
|
||||
*
|
||||
* @param stream The stream to select.
|
||||
* @return true if the stream was found and selected, false otherwise.
|
||||
*/
|
||||
fun selectAudioStream(stream: MediaStream): Boolean {
|
||||
require(stream.type == MediaStreamType.AUDIO)
|
||||
if (mediaStreams[stream.index] !== stream) {
|
||||
return false
|
||||
}
|
||||
|
||||
selectedAudioStream = stream
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the specified [subtitle stream][stream] in the source.
|
||||
*
|
||||
* @param stream The stream to select, or null to disable subtitles.
|
||||
* @return true if the stream was found and selected, false otherwise.
|
||||
*/
|
||||
fun selectSubtitleStream(stream: MediaStream?): Boolean {
|
||||
if (stream == null) {
|
||||
selectedSubtitleStream = null
|
||||
return true
|
||||
}
|
||||
|
||||
require(stream.type == MediaStreamType.SUBTITLE)
|
||||
if (mediaStreams[stream.index] !== stream) {
|
||||
return false
|
||||
}
|
||||
|
||||
selectedSubtitleStream = stream
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the media stream within the embedded streams.
|
||||
* Useful for handling track selection in ExoPlayer, where embedded streams are mapped first.
|
||||
*/
|
||||
fun getEmbeddedStreamIndex(mediaStream: MediaStream): Int {
|
||||
var index = 0
|
||||
for (stream in mediaStreams) {
|
||||
when {
|
||||
stream === mediaStream -> return index
|
||||
!stream.isExternal -> index++
|
||||
}
|
||||
}
|
||||
throw IllegalArgumentException("Invalid media stream")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package org.jellyfin.mobile.player.source
|
||||
|
||||
import org.jellyfin.mobile.player.PlayerException
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.exception.ApiClientException
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||
import org.jellyfin.sdk.api.operations.MediaInfoApi
|
||||
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
||||
class MediaSourceResolver(private val apiClient: ApiClient) {
|
||||
private val mediaInfoApi: MediaInfoApi = apiClient.mediaInfoApi
|
||||
private val userLibraryApi: UserLibraryApi = apiClient.userLibraryApi
|
||||
|
||||
@Suppress("ReturnCount")
|
||||
suspend fun resolveMediaSource(
|
||||
itemId: UUID,
|
||||
mediaSourceId: String? = null,
|
||||
deviceProfile: DeviceProfile? = null,
|
||||
maxStreamingBitrate: Int? = null,
|
||||
startTimeTicks: Long? = null,
|
||||
audioStreamIndex: Int? = null,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
autoOpenLiveStream: Boolean = true,
|
||||
): Result<JellyfinMediaSource> {
|
||||
// Load media source info
|
||||
val playSessionId: String
|
||||
val mediaSourceInfo = try {
|
||||
val response by mediaInfoApi.getPostedPlaybackInfo(
|
||||
itemId = itemId,
|
||||
data = PlaybackInfoDto(
|
||||
// We need to remove the dashes so that the server can find the correct media source.
|
||||
// And if we didn't pass the mediaSourceId, our stream indices would silently get ignored.
|
||||
// https://github.com/jellyfin/jellyfin/blob/9a35fd673203cfaf0098138b2768750f4818b3ab/Jellyfin.Api/Helpers/MediaInfoHelper.cs#L196-L201
|
||||
mediaSourceId = mediaSourceId ?: itemId.toString().replace("-", ""),
|
||||
deviceProfile = deviceProfile,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
startTimeTicks = startTimeTicks,
|
||||
audioStreamIndex = audioStreamIndex,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
autoOpenLiveStream = autoOpenLiveStream,
|
||||
),
|
||||
)
|
||||
|
||||
playSessionId = response.playSessionId ?: return Result.failure(PlayerException.UnsupportedContent())
|
||||
|
||||
response.mediaSources.let { sources ->
|
||||
sources.find { source -> source.id?.toUUIDOrNull() == itemId } ?: sources.firstOrNull()
|
||||
} ?: return Result.failure(PlayerException.UnsupportedContent())
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to load media source $itemId")
|
||||
return Result.failure(PlayerException.NetworkFailure(e))
|
||||
}
|
||||
|
||||
// Load additional item info if possible
|
||||
val item = try {
|
||||
userLibraryApi.getItem(itemId).content
|
||||
} catch (e: ApiClientException) {
|
||||
Timber.e(e, "Failed to load item for media source $itemId")
|
||||
null
|
||||
}
|
||||
|
||||
// Create JellyfinMediaSource
|
||||
return try {
|
||||
val source = JellyfinMediaSource(
|
||||
itemId = itemId,
|
||||
item = item,
|
||||
sourceInfo = mediaSourceInfo,
|
||||
playSessionId = playSessionId,
|
||||
liveStreamId = mediaSourceInfo.liveStreamId,
|
||||
maxStreamingBitrate = maxStreamingBitrate,
|
||||
startTimeTicks = startTimeTicks,
|
||||
audioStreamIndex = audioStreamIndex,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
)
|
||||
Result.success(source)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.e(e, "Cannot create JellyfinMediaSource")
|
||||
Result.failure(PlayerException.UnsupportedContent(e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
/**
|
||||
* Represents the type of decoder
|
||||
*/
|
||||
enum class DecoderType {
|
||||
HARDWARE,
|
||||
SOFTWARE,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
|
||||
data class DisplayPreferences(
|
||||
val skipBackLength: Long = Constants.DEFAULT_SEEK_TIME_MS,
|
||||
val skipForwardLength: Long = Constants.DEFAULT_SEEK_TIME_MS,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
data class PlayState(
|
||||
val playWhenReady: Boolean,
|
||||
val position: Long,
|
||||
)
|
||||
|
|
@ -0,0 +1,413 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.OrientationEventListener
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
import android.widget.ImageButton
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.setPadding
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerView
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.PlayerException
|
||||
import org.jellyfin.mobile.player.PlayerViewModel
|
||||
import org.jellyfin.mobile.player.interaction.PlayOptions
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.BackPressInterceptor
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.Constants.DEFAULT_CONTROLS_TIMEOUT_MS
|
||||
import org.jellyfin.mobile.utils.Constants.PIP_MAX_RATIONAL
|
||||
import org.jellyfin.mobile.utils.Constants.PIP_MIN_RATIONAL
|
||||
import org.jellyfin.mobile.utils.SmartOrientationListener
|
||||
import org.jellyfin.mobile.utils.brightness
|
||||
import org.jellyfin.mobile.utils.extensions.aspectRational
|
||||
import org.jellyfin.mobile.utils.extensions.getParcelableCompat
|
||||
import org.jellyfin.mobile.utils.extensions.isLandscape
|
||||
import org.jellyfin.mobile.utils.extensions.keepScreenOn
|
||||
import org.jellyfin.mobile.utils.toast
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.koin.android.ext.android.inject
|
||||
import com.google.android.exoplayer2.ui.R as ExoplayerR
|
||||
|
||||
class PlayerFragment : Fragment(), BackPressInterceptor {
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val viewModel: PlayerViewModel by viewModels()
|
||||
private var _playerBinding: FragmentPlayerBinding? = null
|
||||
private val playerBinding: FragmentPlayerBinding get() = _playerBinding!!
|
||||
private val playerView: PlayerView get() = playerBinding.playerView
|
||||
private val playerOverlay: View get() = playerBinding.playerOverlay
|
||||
private val loadingIndicator: View get() = playerBinding.loadingIndicator
|
||||
private var _playerControlsBinding: ExoPlayerControlViewBinding? = null
|
||||
private val playerControlsBinding: ExoPlayerControlViewBinding get() = _playerControlsBinding!!
|
||||
private val playerControlsView: View get() = playerControlsBinding.root
|
||||
private val toolbar: Toolbar get() = playerControlsBinding.toolbar
|
||||
private val fullscreenSwitcher: ImageButton get() = playerControlsBinding.fullscreenSwitcher
|
||||
private var playerMenus: PlayerMenus? = null
|
||||
|
||||
private lateinit var playerFullscreenHelper: PlayerFullscreenHelper
|
||||
lateinit var playerLockScreenHelper: PlayerLockScreenHelper
|
||||
lateinit var playerGestureHelper: PlayerGestureHelper
|
||||
|
||||
private val currentVideoStream: MediaStream?
|
||||
get() = viewModel.mediaSourceOrNull?.selectedVideoStream
|
||||
|
||||
/**
|
||||
* Listener that watches the current device orientation.
|
||||
* It makes sure that the orientation sensor can still be used (if enabled)
|
||||
* after toggling the orientation through the fullscreen button.
|
||||
*
|
||||
* If the requestedOrientation was reset directly after setting it in the fullscreenSwitcher click handler,
|
||||
* the orientation would get reverted before the user had any chance to rotate the device to the desired position.
|
||||
*/
|
||||
private val orientationListener: OrientationEventListener by lazy { SmartOrientationListener(requireActivity()) }
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val window = requireActivity().window
|
||||
playerFullscreenHelper = PlayerFullscreenHelper(window)
|
||||
|
||||
// Observe ViewModel
|
||||
viewModel.player.observe(this) { player ->
|
||||
playerView.player = player
|
||||
if (player == null) parentFragmentManager.popBackStack()
|
||||
}
|
||||
viewModel.playerState.observe(this) { playerState ->
|
||||
val isPlaying = viewModel.playerOrNull?.isPlaying == true
|
||||
requireActivity().window.keepScreenOn = isPlaying
|
||||
loadingIndicator.isVisible = playerState == Player.STATE_BUFFERING
|
||||
}
|
||||
viewModel.decoderType.observe(this) { type ->
|
||||
playerMenus?.updatedSelectedDecoder(type)
|
||||
}
|
||||
viewModel.error.observe(this) { message ->
|
||||
val safeMessage = message.ifEmpty { requireContext().getString(R.string.player_error_unspecific_exception) }
|
||||
requireContext().toast(safeMessage)
|
||||
}
|
||||
viewModel.queueManager.currentMediaSource.observe(this) { mediaSource ->
|
||||
if (mediaSource.selectedVideoStream?.isLandscape == false) {
|
||||
// For portrait videos, immediately enable fullscreen
|
||||
playerFullscreenHelper.enableFullscreen()
|
||||
} else if (appPreferences.exoPlayerStartLandscapeVideoInLandscape) {
|
||||
// Auto-switch to landscape for landscape videos if enabled
|
||||
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
}
|
||||
|
||||
// Update title and player menus
|
||||
toolbar.title = mediaSource.name
|
||||
playerMenus?.onQueueItemChanged(mediaSource, viewModel.queueManager.hasNext())
|
||||
}
|
||||
|
||||
// Handle fragment arguments, extract playback options and start playback
|
||||
lifecycleScope.launch {
|
||||
val context = requireContext()
|
||||
val playOptions = requireArguments().getParcelableCompat<PlayOptions>(Constants.EXTRA_MEDIA_PLAY_OPTIONS)
|
||||
if (playOptions == null) {
|
||||
context.toast(R.string.player_error_invalid_play_options)
|
||||
return@launch
|
||||
}
|
||||
when (viewModel.queueManager.initializePlaybackQueue(playOptions)) {
|
||||
is PlayerException.InvalidPlayOptions -> context.toast(R.string.player_error_invalid_play_options)
|
||||
is PlayerException.NetworkFailure -> context.toast(R.string.player_error_network_failure)
|
||||
is PlayerException.UnsupportedContent -> context.toast(R.string.player_error_unsupported_content)
|
||||
null -> Unit // success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_playerBinding = FragmentPlayerBinding.inflate(layoutInflater)
|
||||
_playerControlsBinding = ExoPlayerControlViewBinding.bind(playerBinding.root.findViewById(R.id.player_controls))
|
||||
return playerBinding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Insets handling
|
||||
ViewCompat.setOnApplyWindowInsetsListener(playerBinding.root) { _, insets ->
|
||||
playerFullscreenHelper.onWindowInsetsChanged(insets)
|
||||
|
||||
val systemInsets = when {
|
||||
AndroidVersion.isAtLeastR -> insets.getInsetsIgnoringVisibility(WindowInsetsCompat.Type.systemBars())
|
||||
else -> insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
if (playerFullscreenHelper.isFullscreen) {
|
||||
playerView.setPadding(0)
|
||||
playerControlsView.updatePadding(
|
||||
left = systemInsets.left,
|
||||
top = systemInsets.top,
|
||||
right = systemInsets.right,
|
||||
bottom = systemInsets.bottom,
|
||||
)
|
||||
} else {
|
||||
playerView.updatePadding(
|
||||
left = systemInsets.left,
|
||||
top = systemInsets.top,
|
||||
right = systemInsets.right,
|
||||
bottom = systemInsets.bottom,
|
||||
)
|
||||
playerControlsView.setPadding(0) // Padding is handled by PlayerView
|
||||
}
|
||||
playerOverlay.updatePadding(
|
||||
left = systemInsets.left,
|
||||
top = systemInsets.top,
|
||||
right = systemInsets.right,
|
||||
bottom = systemInsets.bottom,
|
||||
)
|
||||
|
||||
// Update fullscreen switcher icon
|
||||
val fullscreenDrawable = when {
|
||||
playerFullscreenHelper.isFullscreen -> R.drawable.ic_fullscreen_exit_white_32dp
|
||||
else -> R.drawable.ic_fullscreen_enter_white_32dp
|
||||
}
|
||||
fullscreenSwitcher.setImageResource(fullscreenDrawable)
|
||||
|
||||
insets
|
||||
}
|
||||
|
||||
// Handle toolbar back button
|
||||
toolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() }
|
||||
|
||||
// Create playback menus
|
||||
playerMenus = PlayerMenus(this, playerBinding, playerControlsBinding)
|
||||
|
||||
// Set controller timeout
|
||||
suppressControllerAutoHide(false)
|
||||
|
||||
playerLockScreenHelper = PlayerLockScreenHelper(this, playerBinding, orientationListener)
|
||||
playerGestureHelper = PlayerGestureHelper(this, playerBinding, playerLockScreenHelper)
|
||||
|
||||
// Handle fullscreen switcher
|
||||
fullscreenSwitcher.setOnClickListener {
|
||||
toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
orientationListener.enable()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// When returning from another app, fullscreen mode for landscape orientation has to be set again
|
||||
if (isLandscape()) {
|
||||
playerFullscreenHelper.enableFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle current orientation and update fullscreen state and switcher icon
|
||||
*/
|
||||
private fun updateFullscreenState(configuration: Configuration) {
|
||||
// Do not handle any orientation changes while being in Picture-in-Picture mode
|
||||
if (AndroidVersion.isAtLeastN && requireActivity().isInPictureInPictureMode) {
|
||||
return
|
||||
}
|
||||
|
||||
when {
|
||||
isLandscape(configuration) -> {
|
||||
// Landscape orientation is always fullscreen
|
||||
playerFullscreenHelper.enableFullscreen()
|
||||
}
|
||||
currentVideoStream?.isLandscape != false -> {
|
||||
// Disable fullscreen for landscape video in portrait orientation
|
||||
playerFullscreenHelper.disableFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fullscreen.
|
||||
*
|
||||
* If playing a portrait video, this just hides the status and navigation bars.
|
||||
* For landscape videos, additionally the screen gets rotated.
|
||||
*/
|
||||
private fun toggleFullscreen() {
|
||||
val videoTrack = currentVideoStream
|
||||
if (videoTrack == null || videoTrack.isLandscape) {
|
||||
val current = resources.configuration.orientation
|
||||
requireActivity().requestedOrientation = when (current) {
|
||||
Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
|
||||
else -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
}
|
||||
// No need to call playerFullscreenHelper in this case,
|
||||
// since the configuration change triggers updateFullscreenState,
|
||||
// which does it for us.
|
||||
} else {
|
||||
playerFullscreenHelper.toggleFullscreen()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, the player controls will show indefinitely
|
||||
*/
|
||||
fun suppressControllerAutoHide(suppress: Boolean) {
|
||||
playerView.controllerShowTimeoutMs = if (suppress) -1 else DEFAULT_CONTROLS_TIMEOUT_MS
|
||||
}
|
||||
|
||||
fun isLandscape(configuration: Configuration = resources.configuration) =
|
||||
configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||
|
||||
fun onRewind() = viewModel.rewind()
|
||||
|
||||
fun onFastForward() = viewModel.fastForward()
|
||||
|
||||
/**
|
||||
* @param callback called if track selection was successful and UI needs to be updated
|
||||
*/
|
||||
fun onAudioTrackSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
|
||||
if (viewModel.trackSelectionHelper.selectAudioTrack(index)) {
|
||||
callback.onTrackSelected(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param callback called if track selection was successful and UI needs to be updated
|
||||
*/
|
||||
fun onSubtitleSelected(index: Int, callback: TrackSelectionCallback): Job = lifecycleScope.launch {
|
||||
if (viewModel.trackSelectionHelper.selectSubtitleTrack(index)) {
|
||||
callback.onTrackSelected(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle subtitles, selecting the first by [MediaStream.index] if there are multiple.
|
||||
*
|
||||
* @return true if subtitles are enabled now, false if not
|
||||
*/
|
||||
fun toggleSubtitles(callback: TrackSelectionCallback) = lifecycleScope.launch {
|
||||
callback.onTrackSelected(viewModel.trackSelectionHelper.toggleSubtitles())
|
||||
}
|
||||
|
||||
fun onBitrateChanged(bitrate: Int?, callback: TrackSelectionCallback) = lifecycleScope.launch {
|
||||
callback.onTrackSelected(viewModel.changeBitrate(bitrate))
|
||||
}
|
||||
|
||||
/**
|
||||
* @return true if the playback speed was changed
|
||||
*/
|
||||
fun onSpeedSelected(speed: Float): Boolean {
|
||||
return viewModel.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
fun onDecoderSelected(type: DecoderType) {
|
||||
viewModel.updateDecoderType(type)
|
||||
}
|
||||
|
||||
fun onSkipToPrevious() {
|
||||
viewModel.skipToPrevious()
|
||||
}
|
||||
|
||||
fun onSkipToNext() {
|
||||
viewModel.skipToNext()
|
||||
}
|
||||
|
||||
fun onPopupDismissed() {
|
||||
if (!AndroidVersion.isAtLeastR) {
|
||||
updateFullscreenState(resources.configuration)
|
||||
}
|
||||
}
|
||||
|
||||
fun onUserLeaveHint() {
|
||||
if (AndroidVersion.isAtLeastN && viewModel.playerOrNull?.isPlaying == true) {
|
||||
requireActivity().enterPictureInPicture()
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("NestedBlockDepth")
|
||||
@RequiresApi(Build.VERSION_CODES.N)
|
||||
private fun Activity.enterPictureInPicture() {
|
||||
if (AndroidVersion.isAtLeastO) {
|
||||
val params = PictureInPictureParams.Builder().apply {
|
||||
val aspectRational = currentVideoStream?.aspectRational?.let { aspectRational ->
|
||||
when {
|
||||
aspectRational < PIP_MIN_RATIONAL -> PIP_MIN_RATIONAL
|
||||
aspectRational > PIP_MAX_RATIONAL -> PIP_MAX_RATIONAL
|
||||
else -> aspectRational
|
||||
}
|
||||
}
|
||||
setAspectRatio(aspectRational)
|
||||
val contentFrame: View = playerView.findViewById(ExoplayerR.id.exo_content_frame)
|
||||
val contentRect = with(contentFrame) {
|
||||
val (x, y) = intArrayOf(0, 0).also(::getLocationInWindow)
|
||||
Rect(x, y, x + width, y + height)
|
||||
}
|
||||
setSourceRectHint(contentRect)
|
||||
}.build()
|
||||
enterPictureInPictureMode(params)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
|
||||
playerView.useController = !isInPictureInPictureMode
|
||||
if (isInPictureInPictureMode) {
|
||||
playerMenus?.dismissPlaybackInfo()
|
||||
playerLockScreenHelper.hideUnlockButton()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
updateFullscreenState(newConfig)
|
||||
playerGestureHelper.handleConfiguration(newConfig)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
orientationListener.disable()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
// Detach player from PlayerView
|
||||
playerView.player = null
|
||||
|
||||
// Set binding references to null
|
||||
_playerBinding = null
|
||||
_playerControlsBinding = null
|
||||
playerMenus = null
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
with(requireActivity()) {
|
||||
// Reset screen orientation
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
playerFullscreenHelper.disableFullscreen()
|
||||
// Reset screen brightness
|
||||
window.brightness = BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import android.view.View
|
||||
import android.view.Window
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.extensions.hasFlag
|
||||
|
||||
class PlayerFullscreenHelper(private val window: Window) {
|
||||
private val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||
var isFullscreen: Boolean = false
|
||||
private set
|
||||
|
||||
fun onWindowInsetsChanged(insets: WindowInsetsCompat) {
|
||||
isFullscreen = when {
|
||||
AndroidVersion.isAtLeastR -> {
|
||||
// Type.systemBars() doesn't work here because this would also check for the navigation bar
|
||||
// which doesn't exist on all devices
|
||||
!insets.isVisible(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
else -> {
|
||||
@Suppress("DEPRECATION")
|
||||
window.decorView.systemUiVisibility.hasFlag(View.SYSTEM_UI_FLAG_FULLSCREEN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun enableFullscreen() {
|
||||
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
|
||||
fun disableFullscreen() {
|
||||
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
|
||||
fun toggleFullscreen() {
|
||||
if (isFullscreen) disableFullscreen() else enableFullscreen()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.media.AudioManager
|
||||
import android.provider.Settings
|
||||
import android.view.GestureDetector
|
||||
import android.view.MotionEvent
|
||||
import android.view.ScaleGestureDetector
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_OFF
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.postDelayed
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout
|
||||
import com.google.android.exoplayer2.ui.PlayerView
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.brightness
|
||||
import org.jellyfin.mobile.utils.dip
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.math.abs
|
||||
|
||||
class PlayerGestureHelper(
|
||||
private val fragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val playerLockScreenHelper: PlayerLockScreenHelper,
|
||||
) : KoinComponent {
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val audioManager: AudioManager by lazy { fragment.requireActivity().getSystemService()!! }
|
||||
private val playerView: PlayerView by playerBinding::playerView
|
||||
private val gestureIndicatorOverlayLayout: LinearLayout by playerBinding::gestureOverlayLayout
|
||||
private val gestureIndicatorOverlayImage: ImageView by playerBinding::gestureOverlayImage
|
||||
private val gestureIndicatorOverlayProgress: ProgressBar by playerBinding::gestureOverlayProgress
|
||||
|
||||
init {
|
||||
if (appPreferences.exoPlayerRememberBrightness) {
|
||||
fragment.requireActivity().window.brightness = appPreferences.exoPlayerBrightness
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether video content should fill the screen, cutting off unwanted content on the sides.
|
||||
* Useful on wide-screen phones to remove black bars from some movies.
|
||||
*/
|
||||
private var isZoomEnabled = false
|
||||
|
||||
/**
|
||||
* Tracks a value during a swipe gesture (between multiple onScroll calls).
|
||||
* When the gesture starts it's reset to an initial value and gets increased or decreased
|
||||
* (depending on the direction) as the gesture progresses.
|
||||
*/
|
||||
private var swipeGestureValueTracker = -1f
|
||||
|
||||
/**
|
||||
* Runnable that hides [playerView] controller
|
||||
*/
|
||||
private val hidePlayerViewControllerAction = Runnable {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runnable that hides [gestureIndicatorOverlayLayout]
|
||||
*/
|
||||
private val hideGestureIndicatorOverlayAction = Runnable {
|
||||
gestureIndicatorOverlayLayout.isVisible = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles taps when controls are locked
|
||||
*/
|
||||
private val unlockDetector = GestureDetector(
|
||||
playerView.context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
playerLockScreenHelper.peekUnlockButton()
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles double tap to seek and brightness/volume gestures
|
||||
*/
|
||||
private val gestureDetector = GestureDetector(
|
||||
playerView.context,
|
||||
object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onDoubleTap(e: MotionEvent): Boolean {
|
||||
val viewWidth = playerView.measuredWidth
|
||||
val viewHeight = playerView.measuredHeight
|
||||
val viewCenterX = viewWidth / 2
|
||||
val viewCenterY = viewHeight / 2
|
||||
val isFastForward = e.x.toInt() > viewCenterX
|
||||
|
||||
// Show ripple effect
|
||||
playerView.foreground?.apply {
|
||||
val left = if (isFastForward) viewCenterX else 0
|
||||
val right = if (isFastForward) viewWidth else viewCenterX
|
||||
setBounds(left, viewCenterY - viewCenterX / 2, right, viewCenterY + viewCenterX / 2)
|
||||
setHotspot(e.x, e.y)
|
||||
state = intArrayOf(android.R.attr.state_enabled, android.R.attr.state_pressed)
|
||||
playerView.postDelayed(Constants.DOUBLE_TAP_RIPPLE_DURATION_MS) {
|
||||
state = IntArray(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Fast-forward/rewind
|
||||
with(fragment) { if (isFastForward) onFastForward() else onRewind() }
|
||||
|
||||
// Cancel previous runnable to not hide controller while seeking
|
||||
playerView.removeCallbacks(hidePlayerViewControllerAction)
|
||||
|
||||
// Ensure controller gets hidden after seeking
|
||||
playerView.postDelayed(hidePlayerViewControllerAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
|
||||
playerView.apply {
|
||||
if (!isControllerVisible) showController() else hideController()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScroll(
|
||||
firstEvent: MotionEvent?,
|
||||
currentEvent: MotionEvent,
|
||||
distanceX: Float,
|
||||
distanceY: Float,
|
||||
): Boolean {
|
||||
if (!appPreferences.exoPlayerAllowSwipeGestures) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check whether swipe was started in excluded region
|
||||
val exclusionSize = playerView.resources.dip(Constants.SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL)
|
||||
if (
|
||||
firstEvent == null ||
|
||||
firstEvent.y < exclusionSize ||
|
||||
firstEvent.y > playerView.height - exclusionSize
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check whether swipe was oriented vertically
|
||||
if (abs(distanceY / distanceX) < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
val viewCenterX = playerView.measuredWidth / 2
|
||||
|
||||
// Distance to swipe to go from min to max
|
||||
val distanceFull = playerView.measuredHeight * Constants.FULL_SWIPE_RANGE_SCREEN_RATIO
|
||||
val ratioChange = distanceY / distanceFull
|
||||
|
||||
if (firstEvent.x.toInt() > viewCenterX) {
|
||||
// Swiping on the right, change volume
|
||||
|
||||
val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (swipeGestureValueTracker == -1f) swipeGestureValueTracker = currentVolume.toFloat()
|
||||
|
||||
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val change = ratioChange * maxVolume
|
||||
swipeGestureValueTracker += change
|
||||
|
||||
val toSet = swipeGestureValueTracker.toInt().coerceIn(0, maxVolume)
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, toSet, 0)
|
||||
|
||||
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_volume_white_24dp)
|
||||
gestureIndicatorOverlayProgress.max = maxVolume
|
||||
gestureIndicatorOverlayProgress.progress = toSet
|
||||
} else {
|
||||
// Swiping on the left, change brightness
|
||||
|
||||
val window = fragment.requireActivity().window
|
||||
val brightnessRange = BRIGHTNESS_OVERRIDE_OFF..BRIGHTNESS_OVERRIDE_FULL
|
||||
|
||||
// Initialize on first swipe
|
||||
if (swipeGestureValueTracker == -1f) {
|
||||
val brightness = window.brightness
|
||||
swipeGestureValueTracker = when (brightness) {
|
||||
in brightnessRange -> brightness
|
||||
else -> {
|
||||
Settings.System.getFloat(
|
||||
fragment.requireActivity().contentResolver,
|
||||
Settings.System.SCREEN_BRIGHTNESS,
|
||||
) / Constants.SCREEN_BRIGHTNESS_MAX
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
swipeGestureValueTracker = (swipeGestureValueTracker + ratioChange).coerceIn(brightnessRange)
|
||||
window.brightness = swipeGestureValueTracker
|
||||
if (appPreferences.exoPlayerRememberBrightness) {
|
||||
appPreferences.exoPlayerBrightness = swipeGestureValueTracker
|
||||
}
|
||||
|
||||
gestureIndicatorOverlayImage.setImageResource(R.drawable.ic_brightness_white_24dp)
|
||||
gestureIndicatorOverlayProgress.max = Constants.PERCENT_MAX
|
||||
gestureIndicatorOverlayProgress.progress = (swipeGestureValueTracker * Constants.PERCENT_MAX).toInt()
|
||||
}
|
||||
|
||||
gestureIndicatorOverlayLayout.isVisible = true
|
||||
return true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Handles scale/zoom gesture
|
||||
*/
|
||||
private val zoomGestureDetector = ScaleGestureDetector(
|
||||
playerView.context,
|
||||
object : ScaleGestureDetector.OnScaleGestureListener {
|
||||
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean = fragment.isLandscape()
|
||||
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val scaleFactor = detector.scaleFactor
|
||||
if (abs(scaleFactor - Constants.ZOOM_SCALE_BASE) > Constants.ZOOM_SCALE_THRESHOLD) {
|
||||
isZoomEnabled = scaleFactor > 1
|
||||
updateZoomMode(isZoomEnabled)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onScaleEnd(detector: ScaleGestureDetector) = Unit
|
||||
},
|
||||
).apply { isQuickScaleEnabled = false }
|
||||
|
||||
init {
|
||||
@Suppress("ClickableViewAccessibility")
|
||||
playerView.setOnTouchListener { _, event ->
|
||||
if (playerView.useController) {
|
||||
when (event.pointerCount) {
|
||||
1 -> gestureDetector.onTouchEvent(event)
|
||||
2 -> zoomGestureDetector.onTouchEvent(event)
|
||||
}
|
||||
} else {
|
||||
unlockDetector.onTouchEvent(event)
|
||||
}
|
||||
if (event.action == MotionEvent.ACTION_UP) {
|
||||
// Hide gesture indicator after timeout, if shown
|
||||
gestureIndicatorOverlayLayout.apply {
|
||||
if (isVisible) {
|
||||
removeCallbacks(hideGestureIndicatorOverlayAction)
|
||||
postDelayed(
|
||||
hideGestureIndicatorOverlayAction,
|
||||
Constants.DEFAULT_CENTER_OVERLAY_TIMEOUT_MS.toLong(),
|
||||
)
|
||||
}
|
||||
}
|
||||
swipeGestureValueTracker = -1f
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun handleConfiguration(newConfig: Configuration) {
|
||||
updateZoomMode(fragment.isLandscape(newConfig) && isZoomEnabled)
|
||||
}
|
||||
|
||||
private fun updateZoomMode(enabled: Boolean) {
|
||||
playerView.resizeMode = if (enabled) AspectRatioFrameLayout.RESIZE_MODE_ZOOM else AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.OrientationEventListener
|
||||
import android.widget.ImageButton
|
||||
import androidx.core.view.isVisible
|
||||
import com.google.android.exoplayer2.ui.PlayerView
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.utils.AndroidVersion
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.extensions.lockOrientation
|
||||
import org.jellyfin.mobile.utils.isAutoRotateOn
|
||||
|
||||
class PlayerLockScreenHelper(
|
||||
private val playerFragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val orientationListener: OrientationEventListener,
|
||||
) {
|
||||
private val playerView: PlayerView by playerBinding::playerView
|
||||
private val unlockScreenButton: ImageButton by playerBinding::unlockScreenButton
|
||||
|
||||
/**
|
||||
* Runnable that hides the unlock screen button, used by [peekUnlockButton]
|
||||
*/
|
||||
private val hideUnlockButtonAction = Runnable {
|
||||
hideUnlockButton()
|
||||
}
|
||||
|
||||
init {
|
||||
// Handle unlock action
|
||||
unlockScreenButton.setOnClickListener {
|
||||
unlockScreen()
|
||||
}
|
||||
}
|
||||
|
||||
fun lockScreen() {
|
||||
playerView.useController = false
|
||||
orientationListener.disable()
|
||||
playerFragment.requireActivity().lockOrientation()
|
||||
peekUnlockButton()
|
||||
}
|
||||
|
||||
private fun unlockScreen() {
|
||||
hideUnlockButton()
|
||||
val activity = playerFragment.requireActivity()
|
||||
if (activity.isAutoRotateOn()) {
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
orientationListener.enable()
|
||||
if (!AndroidVersion.isAtLeastN || !activity.isInPictureInPictureMode) {
|
||||
playerView.useController = true
|
||||
playerView.apply {
|
||||
if (!isControllerVisible) showController()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun peekUnlockButton() {
|
||||
playerView.removeCallbacks(hideUnlockButtonAction)
|
||||
unlockScreenButton.isVisible = true
|
||||
playerView.postDelayed(hideUnlockButtonAction, Constants.DEFAULT_CONTROLS_TIMEOUT_MS.toLong())
|
||||
}
|
||||
|
||||
fun hideUnlockButton() {
|
||||
unlockScreenButton.isVisible = false
|
||||
}
|
||||
}
|
||||
350
app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Normal file
350
app/src/main/java/org/jellyfin/mobile/player/ui/PlayerMenus.kt
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageButton
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.get
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.size
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.databinding.ExoPlayerControlViewBinding
|
||||
import org.jellyfin.mobile.databinding.FragmentPlayerBinding
|
||||
import org.jellyfin.mobile.player.qualityoptions.QualityOptionsProvider
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Provides a menu UI for audio, subtitle and video stream selection
|
||||
*/
|
||||
class PlayerMenus(
|
||||
private val fragment: PlayerFragment,
|
||||
private val playerBinding: FragmentPlayerBinding,
|
||||
private val playerControlsBinding: ExoPlayerControlViewBinding,
|
||||
) : PopupMenu.OnDismissListener,
|
||||
KoinComponent {
|
||||
|
||||
private val context = playerBinding.root.context
|
||||
private val qualityOptionsProvider: QualityOptionsProvider by inject()
|
||||
private val previousButton: View by playerControlsBinding::previousButton
|
||||
private val nextButton: View by playerControlsBinding::nextButton
|
||||
private val lockScreenButton: View by playerControlsBinding::lockScreenButton
|
||||
private val audioStreamsButton: View by playerControlsBinding::audioStreamsButton
|
||||
private val subtitlesButton: ImageButton by playerControlsBinding::subtitlesButton
|
||||
private val speedButton: View by playerControlsBinding::speedButton
|
||||
private val qualityButton: View by playerControlsBinding::qualityButton
|
||||
private val decoderButton: View by playerControlsBinding::decoderButton
|
||||
private val infoButton: View by playerControlsBinding::infoButton
|
||||
private val playbackInfo: TextView by playerBinding::playbackInfo
|
||||
private val audioStreamsMenu: PopupMenu = createAudioStreamsMenu()
|
||||
private val subtitlesMenu: PopupMenu = createSubtitlesMenu()
|
||||
private val speedMenu: PopupMenu = createSpeedMenu()
|
||||
private val qualityMenu: PopupMenu = createQualityMenu()
|
||||
private val decoderMenu: PopupMenu = createDecoderMenu()
|
||||
|
||||
private var subtitleCount = 0
|
||||
private var subtitlesEnabled = false
|
||||
|
||||
init {
|
||||
previousButton.setOnClickListener {
|
||||
fragment.onSkipToPrevious()
|
||||
}
|
||||
nextButton.setOnClickListener {
|
||||
fragment.onSkipToNext()
|
||||
}
|
||||
lockScreenButton.setOnClickListener {
|
||||
fragment.playerLockScreenHelper.lockScreen()
|
||||
}
|
||||
audioStreamsButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
audioStreamsMenu.show()
|
||||
}
|
||||
subtitlesButton.setOnClickListener {
|
||||
when (subtitleCount) {
|
||||
0 -> return@setOnClickListener
|
||||
1 -> {
|
||||
fragment.toggleSubtitles { enabled ->
|
||||
subtitlesEnabled = enabled
|
||||
updateSubtitlesButton()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
subtitlesMenu.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
speedButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
speedMenu.show()
|
||||
}
|
||||
qualityButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
qualityMenu.show()
|
||||
}
|
||||
decoderButton.setOnClickListener {
|
||||
fragment.suppressControllerAutoHide(true)
|
||||
decoderMenu.show()
|
||||
}
|
||||
infoButton.setOnClickListener {
|
||||
playbackInfo.isVisible = !playbackInfo.isVisible
|
||||
}
|
||||
playbackInfo.setOnClickListener {
|
||||
dismissPlaybackInfo()
|
||||
}
|
||||
}
|
||||
|
||||
fun onQueueItemChanged(mediaSource: JellyfinMediaSource, hasNext: Boolean) {
|
||||
// previousButton is always enabled and will rewind if at the start of the queue
|
||||
nextButton.isEnabled = hasNext
|
||||
|
||||
val videoStream = mediaSource.selectedVideoStream
|
||||
|
||||
val audioStreams = mediaSource.audioStreams
|
||||
buildMenuItems(
|
||||
audioStreamsMenu.menu,
|
||||
AUDIO_MENU_GROUP,
|
||||
audioStreams,
|
||||
mediaSource.selectedAudioStream,
|
||||
)
|
||||
|
||||
val subtitleStreams = mediaSource.subtitleStreams
|
||||
val selectedSubtitleStream = mediaSource.selectedSubtitleStream
|
||||
buildMenuItems(
|
||||
subtitlesMenu.menu,
|
||||
SUBTITLES_MENU_GROUP,
|
||||
subtitleStreams,
|
||||
selectedSubtitleStream,
|
||||
true,
|
||||
)
|
||||
subtitleCount = subtitleStreams.size
|
||||
subtitlesEnabled = selectedSubtitleStream != null
|
||||
|
||||
updateSubtitlesButton()
|
||||
|
||||
val height = videoStream?.height
|
||||
val width = videoStream?.width
|
||||
if (height != null && width != null) {
|
||||
buildQualityMenu(qualityMenu.menu, mediaSource.maxStreamingBitrate, width, height)
|
||||
} else {
|
||||
qualityButton.isVisible = false
|
||||
}
|
||||
|
||||
val playMethod = context.getString(R.string.playback_info_play_method, mediaSource.playMethod)
|
||||
val videoTracksInfo = buildMediaStreamsInfo(
|
||||
mediaStreams = listOfNotNull(videoStream),
|
||||
prefix = R.string.playback_info_video_streams,
|
||||
maxStreams = MAX_VIDEO_STREAMS_DISPLAY,
|
||||
streamSuffix = { stream ->
|
||||
stream.bitRate?.let { bitrate -> " (${formatBitrate(bitrate.toDouble())})" }.orEmpty()
|
||||
},
|
||||
)
|
||||
val audioTracksInfo = buildMediaStreamsInfo(
|
||||
mediaStreams = audioStreams,
|
||||
prefix = R.string.playback_info_audio_streams,
|
||||
maxStreams = MAX_AUDIO_STREAMS_DISPLAY,
|
||||
streamSuffix = { stream ->
|
||||
stream.language?.let { lang -> " ($lang)" }.orEmpty()
|
||||
},
|
||||
)
|
||||
|
||||
playbackInfo.text = listOf(
|
||||
playMethod,
|
||||
videoTracksInfo,
|
||||
audioTracksInfo,
|
||||
).joinToString("\n\n")
|
||||
}
|
||||
|
||||
private fun buildMediaStreamsInfo(
|
||||
mediaStreams: List<MediaStream>,
|
||||
@StringRes prefix: Int,
|
||||
maxStreams: Int,
|
||||
streamSuffix: (MediaStream) -> String,
|
||||
): String = mediaStreams.joinToString(
|
||||
"\n",
|
||||
"${fragment.getString(prefix)}:\n",
|
||||
limit = maxStreams,
|
||||
truncated = fragment.getString(R.string.playback_info_and_x_more, mediaStreams.size - maxStreams),
|
||||
) { stream ->
|
||||
val title = stream.displayTitle?.takeUnless(String::isEmpty)
|
||||
?: fragment.getString(R.string.playback_info_stream_unknown_title)
|
||||
val suffix = streamSuffix(stream)
|
||||
"- $title$suffix"
|
||||
}
|
||||
|
||||
private fun createSubtitlesMenu() = PopupMenu(context, subtitlesButton).apply {
|
||||
setOnMenuItemClickListener { clickedItem ->
|
||||
// Immediately apply changes to the menu, necessary when direct playing
|
||||
// When transcoding, the updated media source will cause the menu to be rebuilt
|
||||
clickedItem.isChecked = true
|
||||
|
||||
// The itemId is the MediaStream.index of the track
|
||||
val selectedSubtitleStreamIndex = clickedItem.itemId
|
||||
fragment.onSubtitleSelected(selectedSubtitleStreamIndex) {
|
||||
subtitlesEnabled = selectedSubtitleStreamIndex >= 0
|
||||
updateSubtitlesButton()
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createAudioStreamsMenu() = PopupMenu(context, audioStreamsButton).apply {
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
// Immediately apply changes to the menu, necessary when direct playing
|
||||
// When transcoding, the updated media source will cause the menu to be rebuilt
|
||||
clickedItem.isChecked = true
|
||||
|
||||
// The itemId is the MediaStream.index of the track
|
||||
fragment.onAudioTrackSelected(clickedItem.itemId) {}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createSpeedMenu() = PopupMenu(context, speedButton).apply {
|
||||
for (step in SPEED_MENU_STEP_MIN..SPEED_MENU_STEP_MAX) {
|
||||
val newSpeed = step * SPEED_MENU_STEP_SIZE
|
||||
menu.add(SPEED_MENU_GROUP, step, Menu.NONE, "${newSpeed}x").isChecked = newSpeed == 1f
|
||||
}
|
||||
menu.setGroupCheckable(SPEED_MENU_GROUP, true, true)
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
fragment.onSpeedSelected(clickedItem.itemId * SPEED_MENU_STEP_SIZE).also { success ->
|
||||
if (success) clickedItem.isChecked = true
|
||||
}
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createQualityMenu() = PopupMenu(context, qualityButton).apply {
|
||||
setOnMenuItemClickListener { item: MenuItem ->
|
||||
val newBitrate = item.itemId.takeUnless { bitrate -> bitrate == 0 }
|
||||
fragment.onBitrateChanged(newBitrate) {
|
||||
// Ignore callback - menu will be recreated if bitrate changes
|
||||
}
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
private fun createDecoderMenu() = PopupMenu(context, qualityButton).apply {
|
||||
menu.add(
|
||||
DECODER_MENU_GROUP,
|
||||
DecoderType.HARDWARE.ordinal,
|
||||
Menu.NONE,
|
||||
context.getString(R.string.menu_item_hardware_decoding),
|
||||
)
|
||||
menu.add(
|
||||
DECODER_MENU_GROUP,
|
||||
DecoderType.SOFTWARE.ordinal,
|
||||
Menu.NONE,
|
||||
context.getString(R.string.menu_item_software_decoding),
|
||||
)
|
||||
menu.setGroupCheckable(DECODER_MENU_GROUP, true, true)
|
||||
|
||||
setOnMenuItemClickListener { clickedItem: MenuItem ->
|
||||
val type = DecoderType.values()[clickedItem.itemId]
|
||||
fragment.onDecoderSelected(type)
|
||||
clickedItem.isChecked = true
|
||||
true
|
||||
}
|
||||
setOnDismissListener(this@PlayerMenus)
|
||||
}
|
||||
|
||||
fun updatedSelectedDecoder(type: DecoderType) {
|
||||
decoderMenu.menu.findItem(type.ordinal).isChecked = true
|
||||
}
|
||||
|
||||
private fun buildMenuItems(
|
||||
menu: Menu,
|
||||
groupId: Int,
|
||||
mediaStreams: List<MediaStream>,
|
||||
selectedStream: MediaStream?,
|
||||
showNone: Boolean = false,
|
||||
) {
|
||||
menu.clear()
|
||||
val itemNone = when {
|
||||
showNone -> menu.add(groupId, -1, Menu.NONE, fragment.getString(R.string.menu_item_none))
|
||||
else -> null
|
||||
}
|
||||
var selectedItem: MenuItem? = itemNone
|
||||
val menuItems = mediaStreams.map { mediaStream ->
|
||||
val title = mediaStream.displayTitle ?: "${mediaStream.language} (${mediaStream.codec})"
|
||||
menu.add(groupId, mediaStream.index, Menu.NONE, title).also { item ->
|
||||
if (mediaStream === selectedStream) {
|
||||
selectedItem = item
|
||||
}
|
||||
}
|
||||
}
|
||||
menu.setGroupCheckable(groupId, true, true)
|
||||
// Check selected item or first item if possible
|
||||
(selectedItem ?: menuItems.firstOrNull())?.isChecked = true
|
||||
}
|
||||
|
||||
private fun updateSubtitlesButton() {
|
||||
subtitlesButton.isVisible = subtitleCount > 0
|
||||
val stateSet = intArrayOf(android.R.attr.state_checked * if (subtitlesEnabled) 1 else -1)
|
||||
subtitlesButton.setImageState(stateSet, true)
|
||||
}
|
||||
|
||||
private fun buildQualityMenu(menu: Menu, maxStreamingBitrate: Int?, videoWidth: Int, videoHeight: Int) {
|
||||
menu.clear()
|
||||
val options = qualityOptionsProvider.getApplicableQualityOptions(videoWidth, videoHeight)
|
||||
options.forEach { option ->
|
||||
val title = when (val bitrate = option.bitrate) {
|
||||
0 -> context.getString(R.string.menu_item_auto)
|
||||
else -> "${option.maxHeight}p - ${formatBitrate(bitrate.toDouble())}"
|
||||
}
|
||||
menu.add(QUALITY_MENU_GROUP, option.bitrate, Menu.NONE, title)
|
||||
}
|
||||
menu.setGroupCheckable(QUALITY_MENU_GROUP, true, true)
|
||||
|
||||
val selection = maxStreamingBitrate?.let(menu::findItem) ?: menu[menu.size - 1] // Last element is "auto"
|
||||
selection.isChecked = true
|
||||
}
|
||||
|
||||
fun dismissPlaybackInfo() {
|
||||
playbackInfo.isVisible = false
|
||||
}
|
||||
|
||||
override fun onDismiss(menu: PopupMenu) {
|
||||
fragment.suppressControllerAutoHide(false)
|
||||
fragment.onPopupDismissed()
|
||||
}
|
||||
|
||||
private fun formatBitrate(bitrate: Double): String {
|
||||
val (value, unit) = when {
|
||||
bitrate > BITRATE_MEGA_BIT -> bitrate / BITRATE_MEGA_BIT to " Mbps"
|
||||
bitrate > BITRATE_KILO_BIT -> bitrate / BITRATE_KILO_BIT to " kbps"
|
||||
else -> bitrate to " bps"
|
||||
}
|
||||
|
||||
// Remove unnecessary trailing zeros
|
||||
val formatted = "%.2f".format(Locale.getDefault(), value).removeSuffix(".00")
|
||||
return formatted + unit
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SUBTITLES_MENU_GROUP = 0
|
||||
private const val AUDIO_MENU_GROUP = 1
|
||||
private const val SPEED_MENU_GROUP = 2
|
||||
private const val QUALITY_MENU_GROUP = 3
|
||||
private const val DECODER_MENU_GROUP = 4
|
||||
|
||||
private const val MAX_VIDEO_STREAMS_DISPLAY = 3
|
||||
private const val MAX_AUDIO_STREAMS_DISPLAY = 5
|
||||
|
||||
private const val BITRATE_MEGA_BIT = 1_000_000
|
||||
private const val BITRATE_KILO_BIT = 1_000
|
||||
|
||||
private const val SPEED_MENU_STEP_SIZE = 0.25f
|
||||
private const val SPEED_MENU_STEP_MIN = 2 // → 0.5x
|
||||
private const val SPEED_MENU_STEP_MAX = 8 // → 2x
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package org.jellyfin.mobile.player.ui
|
||||
|
||||
fun interface TrackSelectionCallback {
|
||||
fun onTrackSelected(success: Boolean)
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package org.jellyfin.mobile.settings;
|
||||
|
||||
|
||||
import androidx.annotation.StringDef;
|
||||
|
||||
@StringDef({
|
||||
ExternalPlayerPackage.MPV_PLAYER,
|
||||
ExternalPlayerPackage.MX_PLAYER_FREE,
|
||||
ExternalPlayerPackage.MX_PLAYER_PRO,
|
||||
ExternalPlayerPackage.VLC_PLAYER,
|
||||
ExternalPlayerPackage.SYSTEM_DEFAULT
|
||||
})
|
||||
public @interface ExternalPlayerPackage {
|
||||
String MPV_PLAYER = "is.xyz.mpv";
|
||||
String MX_PLAYER_FREE = "com.mxtech.videoplayer.ad";
|
||||
String MX_PLAYER_PRO = "com.mxtech.videoplayer.pro";
|
||||
String VLC_PLAYER = "org.videolan.vlc";
|
||||
String SYSTEM_DEFAULT = "~system~";
|
||||
}
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
package org.jellyfin.mobile.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
|
||||
import androidx.fragment.app.Fragment
|
||||
import de.Maxr1998.modernpreferences.Preference
|
||||
import de.Maxr1998.modernpreferences.PreferencesAdapter
|
||||
import de.Maxr1998.modernpreferences.helpers.categoryHeader
|
||||
import de.Maxr1998.modernpreferences.helpers.checkBox
|
||||
import de.Maxr1998.modernpreferences.helpers.defaultOnCheckedChange
|
||||
import de.Maxr1998.modernpreferences.helpers.defaultOnClick
|
||||
import de.Maxr1998.modernpreferences.helpers.defaultOnSelectionChange
|
||||
import de.Maxr1998.modernpreferences.helpers.pref
|
||||
import de.Maxr1998.modernpreferences.helpers.screen
|
||||
import de.Maxr1998.modernpreferences.helpers.singleChoice
|
||||
import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference
|
||||
import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.databinding.FragmentSettingsBinding
|
||||
import org.jellyfin.mobile.utils.BackPressInterceptor
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.DownloadMethod
|
||||
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
|
||||
import org.jellyfin.mobile.utils.extensions.requireMainActivity
|
||||
import org.jellyfin.mobile.utils.getDownloadsPaths
|
||||
import org.jellyfin.mobile.utils.isPackageInstalled
|
||||
import org.jellyfin.mobile.utils.withThemedContext
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
class SettingsFragment : Fragment(), BackPressInterceptor {
|
||||
|
||||
private val appPreferences: AppPreferences by inject()
|
||||
private val settingsAdapter: PreferencesAdapter by lazy { PreferencesAdapter(buildSettingsScreen()) }
|
||||
private lateinit var startLandscapeVideoInLandscapePreference: CheckBoxPreference
|
||||
private lateinit var swipeGesturesPreference: CheckBoxPreference
|
||||
private lateinit var rememberBrightnessPreference: Preference
|
||||
private lateinit var backgroundAudioPreference: Preference
|
||||
private lateinit var directPlayAssPreference: Preference
|
||||
private lateinit var externalPlayerChoicePreference: Preference
|
||||
|
||||
init {
|
||||
Preference.Config.titleMaxLines = 2
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val localInflater = inflater.withThemedContext(requireContext(), R.style.AppTheme_Settings)
|
||||
val binding = FragmentSettingsBinding.inflate(localInflater, container, false)
|
||||
binding.root.applyWindowInsetsAsMargins()
|
||||
binding.toolbar.setTitle(R.string.activity_name_settings)
|
||||
requireMainActivity().apply {
|
||||
setSupportActionBar(binding.toolbar)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
}
|
||||
binding.recyclerView.adapter = settingsAdapter
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onInterceptBackPressed(): Boolean {
|
||||
return settingsAdapter.goBack()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
requireMainActivity().setSupportActionBar(null)
|
||||
}
|
||||
|
||||
@Suppress("LongMethod")
|
||||
private fun buildSettingsScreen() = screen(requireContext()) {
|
||||
collapseIcon = true
|
||||
categoryHeader(PREF_CATEGORY_MUSIC_PLAYER) {
|
||||
titleRes = R.string.pref_category_music_player
|
||||
}
|
||||
checkBox(Constants.PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE) {
|
||||
titleRes = R.string.pref_music_notification_always_dismissible_title
|
||||
summaryRes = R.string.pref_music_notification_always_dismissible_summary_off
|
||||
summaryOnRes = R.string.pref_music_notification_always_dismissible_summary_on
|
||||
}
|
||||
categoryHeader(PREF_CATEGORY_VIDEO_PLAYER) {
|
||||
titleRes = R.string.pref_category_video_player
|
||||
}
|
||||
val videoPlayerOptions = listOf(
|
||||
SelectionItem(VideoPlayerType.WEB_PLAYER, R.string.video_player_web, R.string.video_player_web_description),
|
||||
SelectionItem(
|
||||
VideoPlayerType.EXO_PLAYER,
|
||||
R.string.video_player_integrated,
|
||||
R.string.video_player_native_description,
|
||||
),
|
||||
SelectionItem(
|
||||
VideoPlayerType.EXTERNAL_PLAYER,
|
||||
R.string.video_player_external,
|
||||
R.string.video_player_external_description,
|
||||
),
|
||||
)
|
||||
singleChoice(Constants.PREF_VIDEO_PLAYER_TYPE, videoPlayerOptions) {
|
||||
titleRes = R.string.pref_video_player_type_title
|
||||
initialSelection = VideoPlayerType.WEB_PLAYER
|
||||
defaultOnSelectionChange { selection ->
|
||||
startLandscapeVideoInLandscapePreference.enabled = selection == VideoPlayerType.EXO_PLAYER
|
||||
swipeGesturesPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
|
||||
rememberBrightnessPreference.enabled = selection == VideoPlayerType.EXO_PLAYER && swipeGesturesPreference.checked
|
||||
backgroundAudioPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
|
||||
directPlayAssPreference.enabled = selection == VideoPlayerType.EXO_PLAYER
|
||||
externalPlayerChoicePreference.enabled = selection == VideoPlayerType.EXTERNAL_PLAYER
|
||||
}
|
||||
}
|
||||
startLandscapeVideoInLandscapePreference = checkBox(Constants.PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE) {
|
||||
titleRes = R.string.pref_exoplayer_start_landscape_video_in_landscape
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
|
||||
}
|
||||
swipeGesturesPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES) {
|
||||
titleRes = R.string.pref_exoplayer_allow_brightness_volume_gesture
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
|
||||
defaultValue = true
|
||||
defaultOnCheckedChange { checked ->
|
||||
rememberBrightnessPreference.enabled = checked
|
||||
}
|
||||
}
|
||||
rememberBrightnessPreference = checkBox(Constants.PREF_EXOPLAYER_REMEMBER_BRIGHTNESS) {
|
||||
titleRes = R.string.pref_exoplayer_remember_brightness
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER && appPreferences.exoPlayerAllowSwipeGestures
|
||||
defaultOnCheckedChange { checked ->
|
||||
if (!checked) appPreferences.exoPlayerBrightness = BRIGHTNESS_OVERRIDE_NONE
|
||||
}
|
||||
}
|
||||
backgroundAudioPreference = checkBox(Constants.PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO) {
|
||||
titleRes = R.string.pref_exoplayer_allow_background_audio
|
||||
summaryRes = R.string.pref_exoplayer_allow_background_audio_summary
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
|
||||
}
|
||||
directPlayAssPreference = checkBox(Constants.PREF_EXOPLAYER_DIRECT_PLAY_ASS) {
|
||||
titleRes = R.string.pref_exoplayer_direct_play_ass
|
||||
summaryRes = R.string.pref_exoplayer_direct_play_ass_summary
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXO_PLAYER
|
||||
}
|
||||
|
||||
// Generate available external player options
|
||||
val packageManager = requireContext().packageManager
|
||||
val externalPlayerOptions = listOf(
|
||||
SelectionItem(
|
||||
ExternalPlayerPackage.SYSTEM_DEFAULT,
|
||||
R.string.external_player_system_default,
|
||||
R.string.external_player_system_default_description,
|
||||
),
|
||||
SelectionItem(
|
||||
ExternalPlayerPackage.MPV_PLAYER,
|
||||
R.string.external_player_mpv,
|
||||
R.string.external_player_mpv_description,
|
||||
),
|
||||
SelectionItem(
|
||||
ExternalPlayerPackage.MX_PLAYER_FREE,
|
||||
R.string.external_player_mx_player_free,
|
||||
R.string.external_player_mx_player_free_description,
|
||||
),
|
||||
SelectionItem(
|
||||
ExternalPlayerPackage.MX_PLAYER_PRO,
|
||||
R.string.external_player_mx_player_pro,
|
||||
R.string.external_player_mx_player_pro_description,
|
||||
),
|
||||
SelectionItem(
|
||||
ExternalPlayerPackage.VLC_PLAYER,
|
||||
R.string.external_player_vlc_player,
|
||||
R.string.external_player_vlc_player_description,
|
||||
),
|
||||
).filter { item ->
|
||||
item.key == ExternalPlayerPackage.SYSTEM_DEFAULT || packageManager.isPackageInstalled(item.key)
|
||||
}
|
||||
|
||||
// Revert if current selection isn't available
|
||||
if (!packageManager.isPackageInstalled(appPreferences.externalPlayerApp)) {
|
||||
appPreferences.externalPlayerApp = ExternalPlayerPackage.SYSTEM_DEFAULT
|
||||
}
|
||||
|
||||
externalPlayerChoicePreference = singleChoice(Constants.PREF_EXTERNAL_PLAYER_APP, externalPlayerOptions) {
|
||||
titleRes = R.string.external_player_app
|
||||
enabled = appPreferences.videoPlayerType == VideoPlayerType.EXTERNAL_PLAYER
|
||||
}
|
||||
val subtitleSettingsIntent = Intent(Settings.ACTION_CAPTIONING_SETTINGS)
|
||||
if (subtitleSettingsIntent.resolveActivity(requireContext().packageManager) != null) {
|
||||
pref(Constants.PREF_SUBTITLE_STYLE) {
|
||||
titleRes = R.string.pref_subtitle_style
|
||||
summaryRes = R.string.pref_subtitle_style_summary
|
||||
defaultOnClick {
|
||||
startActivity(subtitleSettingsIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
categoryHeader(PREF_CATEGORY_DOWNLOADS) {
|
||||
titleRes = R.string.pref_category_downloads
|
||||
}
|
||||
|
||||
val downloadMethods = listOf(
|
||||
SelectionItem(
|
||||
DownloadMethod.WIFI_ONLY,
|
||||
R.string.wifi_only,
|
||||
R.string.wifi_only_summary,
|
||||
),
|
||||
SelectionItem(
|
||||
DownloadMethod.MOBILE_DATA,
|
||||
R.string.mobile_data,
|
||||
R.string.mobile_data_summary,
|
||||
),
|
||||
SelectionItem(
|
||||
DownloadMethod.MOBILE_AND_ROAMING,
|
||||
R.string.mobile_data_and_roaming,
|
||||
R.string.mobile_data_and_roaming_summary,
|
||||
),
|
||||
)
|
||||
singleChoice(Constants.PREF_DOWNLOAD_METHOD, downloadMethods) {
|
||||
titleRes = R.string.network_title
|
||||
}
|
||||
|
||||
val downloadsDirs = requireContext().getDownloadsPaths().map { path ->
|
||||
SelectionItem(path, path, null)
|
||||
}
|
||||
singleChoice(Constants.PREF_DOWNLOAD_LOCATION, downloadsDirs) {
|
||||
titleRes = R.string.pref_download_location
|
||||
initialSelection = Environment
|
||||
.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
|
||||
.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREF_CATEGORY_MUSIC_PLAYER = "pref_category_music"
|
||||
const val PREF_CATEGORY_VIDEO_PLAYER = "pref_category_video"
|
||||
const val PREF_CATEGORY_DOWNLOADS = "pref_category_downloads"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.jellyfin.mobile.settings;
|
||||
|
||||
|
||||
import androidx.annotation.StringDef;
|
||||
|
||||
@StringDef({
|
||||
VideoPlayerType.WEB_PLAYER,
|
||||
VideoPlayerType.EXO_PLAYER,
|
||||
VideoPlayerType.EXTERNAL_PLAYER
|
||||
})
|
||||
public @interface VideoPlayerType {
|
||||
String WEB_PLAYER = "webui";
|
||||
String EXO_PLAYER = "exoplayer";
|
||||
String EXTERNAL_PLAYER = "external";
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package org.jellyfin.mobile.setup
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.jellyfin.mobile.MainViewModel
|
||||
import org.jellyfin.mobile.databinding.FragmentComposeBinding
|
||||
import org.jellyfin.mobile.ui.screens.connect.ConnectScreen
|
||||
import org.jellyfin.mobile.ui.utils.AppTheme
|
||||
import org.jellyfin.mobile.utils.Constants
|
||||
import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins
|
||||
import org.koin.androidx.viewmodel.ext.android.activityViewModel
|
||||
|
||||
class ConnectFragment : Fragment() {
|
||||
private val mainViewModel: MainViewModel by activityViewModel()
|
||||
private var _viewBinding: FragmentComposeBinding? = null
|
||||
private val viewBinding get() = _viewBinding!!
|
||||
private val composeView: ComposeView get() = viewBinding.composeView
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_viewBinding = FragmentComposeBinding.inflate(inflater, container, false)
|
||||
return composeView.apply { applyWindowInsetsAsMargins() }
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
// Apply window insets
|
||||
ViewCompat.requestApplyInsets(composeView)
|
||||
|
||||
val encounteredConnectionError = arguments?.getBoolean(Constants.FRAGMENT_CONNECT_EXTRA_ERROR) == true
|
||||
|
||||
composeView.setContent {
|
||||
AppTheme {
|
||||
ConnectScreen(
|
||||
mainViewModel = mainViewModel,
|
||||
showExternalConnectionError = encounteredConnectionError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
package org.jellyfin.mobile.setup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.ui.state.CheckUrlState
|
||||
import org.jellyfin.sdk.Jellyfin
|
||||
import org.jellyfin.sdk.discovery.LocalServerDiscovery
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.api.ServerDiscoveryInfo
|
||||
import timber.log.Timber
|
||||
|
||||
class ConnectionHelper(
|
||||
private val context: Context,
|
||||
private val jellyfin: Jellyfin,
|
||||
) {
|
||||
suspend fun checkServerUrl(enteredUrl: String): CheckUrlState {
|
||||
Timber.i("checkServerUrlAndConnection $enteredUrl")
|
||||
|
||||
val candidates = jellyfin.discovery.getAddressCandidates(enteredUrl)
|
||||
Timber.i("Address candidates are $candidates")
|
||||
|
||||
// Find servers and classify them into groups.
|
||||
// BAD servers are collected in case we need an error message,
|
||||
// GOOD are kept if there's no GREAT one.
|
||||
val badServers = mutableListOf<RecommendedServerInfo>()
|
||||
val goodServers = mutableListOf<RecommendedServerInfo>()
|
||||
val greatServer = jellyfin.discovery.getRecommendedServers(candidates).firstOrNull { recommendedServer ->
|
||||
when (recommendedServer.score) {
|
||||
RecommendedServerInfoScore.GREAT -> true
|
||||
RecommendedServerInfoScore.GOOD -> {
|
||||
goodServers += recommendedServer
|
||||
false
|
||||
}
|
||||
RecommendedServerInfoScore.OK,
|
||||
RecommendedServerInfoScore.BAD,
|
||||
-> {
|
||||
badServers += recommendedServer
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val server = greatServer ?: goodServers.firstOrNull()
|
||||
if (server != null) {
|
||||
val systemInfo = requireNotNull(server.systemInfo)
|
||||
val serverVersion = systemInfo.getOrNull()?.version
|
||||
Timber.i("Found valid server at ${server.address} with rating ${server.score} and version $serverVersion")
|
||||
return CheckUrlState.Success(server.address)
|
||||
}
|
||||
|
||||
// No valid server found, log and show error message
|
||||
val loggedServers = badServers.joinToString { "${it.address}/${it.systemInfo}" }
|
||||
Timber.i("No valid servers found, invalid candidates were: $loggedServers")
|
||||
|
||||
val error = when {
|
||||
badServers.isNotEmpty() -> {
|
||||
val count = badServers.size
|
||||
val (unreachableServers, incompatibleServers) = badServers.partition { result -> result.systemInfo.getOrNull() == null }
|
||||
|
||||
StringBuilder(context.resources.getQuantityString(R.plurals.connection_error_prefix, count, count)).apply {
|
||||
if (unreachableServers.isNotEmpty()) {
|
||||
append("\n\n")
|
||||
append(context.getString(R.string.connection_error_unable_to_reach_sever))
|
||||
append(":\n")
|
||||
append(
|
||||
unreachableServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" },
|
||||
)
|
||||
}
|
||||
if (incompatibleServers.isNotEmpty()) {
|
||||
append("\n\n")
|
||||
append(context.getString(R.string.connection_error_unsupported_version_or_product))
|
||||
append(":\n")
|
||||
append(
|
||||
incompatibleServers.joinToString(separator = "\n") { result -> "\u00b7 ${result.address}" },
|
||||
)
|
||||
}
|
||||
}.toString()
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
return CheckUrlState.Error(error)
|
||||
}
|
||||
|
||||
fun discoverServersAsFlow(): Flow<ServerDiscoveryInfo> =
|
||||
jellyfin.discovery
|
||||
.discoverLocalServers(maxServers = LocalServerDiscovery.DISCOVERY_MAX_SERVERS)
|
||||
.flowOn(Dispatchers.IO)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
package org.jellyfin.mobile.ui.screens.connect
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.jellyfin.mobile.MainViewModel
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.ui.utils.CenterRow
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
mainViewModel: MainViewModel,
|
||||
showExternalConnectionError: Boolean,
|
||||
) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
LogoHeader()
|
||||
ServerSelection(
|
||||
showExternalConnectionError = showExternalConnectionError,
|
||||
onConnected = { hostname ->
|
||||
mainViewModel.switchServer(hostname)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
fun LogoHeader() {
|
||||
CenterRow(
|
||||
modifier = Modifier.padding(vertical = 25.dp),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.app_logo),
|
||||
modifier = Modifier
|
||||
.height(72.dp),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
package org.jellyfin.mobile.ui.screens.connect
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.OutlinedTextField
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextButton
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.ArrowBack
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.ApiClientController
|
||||
import org.jellyfin.mobile.setup.ConnectionHelper
|
||||
import org.jellyfin.mobile.ui.state.CheckUrlState
|
||||
import org.jellyfin.mobile.ui.state.ServerSelectionMode
|
||||
import org.jellyfin.mobile.ui.utils.CenterRow
|
||||
import org.koin.compose.koinInject
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Suppress("LongMethod")
|
||||
@Composable
|
||||
fun ServerSelection(
|
||||
showExternalConnectionError: Boolean,
|
||||
apiClientController: ApiClientController = koinInject(),
|
||||
connectionHelper: ConnectionHelper = koinInject(),
|
||||
onConnected: suspend (String) -> Unit,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
var serverSelectionMode by remember { mutableStateOf(ServerSelectionMode.ADDRESS) }
|
||||
var hostname by remember { mutableStateOf("") }
|
||||
val serverSuggestions = remember { mutableStateListOf<ServerSuggestion>() }
|
||||
var checkUrlState by remember<MutableState<CheckUrlState>> { mutableStateOf(CheckUrlState.Unchecked) }
|
||||
var externalError by remember { mutableStateOf(showExternalConnectionError) }
|
||||
|
||||
// Prefill currently selected server if available
|
||||
LaunchedEffect(Unit) {
|
||||
val server = apiClientController.loadSavedServer()
|
||||
if (server != null) {
|
||||
hostname = server.hostname
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
// Suggest saved servers
|
||||
apiClientController.loadPreviouslyUsedServers().mapTo(serverSuggestions) { server ->
|
||||
ServerSuggestion(
|
||||
type = ServerSuggestion.Type.SAVED,
|
||||
name = server.hostname,
|
||||
address = server.hostname,
|
||||
timestamp = server.lastUsedTimestamp,
|
||||
)
|
||||
}
|
||||
|
||||
// Prepend discovered servers to suggestions
|
||||
connectionHelper.discoverServersAsFlow().collect { serverInfo ->
|
||||
serverSuggestions.removeIf { existing -> existing.address == serverInfo.address }
|
||||
serverSuggestions.add(
|
||||
index = 0,
|
||||
ServerSuggestion(
|
||||
type = ServerSuggestion.Type.DISCOVERED,
|
||||
name = serverInfo.name,
|
||||
address = serverInfo.address,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onSubmit() {
|
||||
externalError = false
|
||||
checkUrlState = CheckUrlState.Pending
|
||||
coroutineScope.launch {
|
||||
val state = connectionHelper.checkServerUrl(hostname)
|
||||
checkUrlState = state
|
||||
if (state is CheckUrlState.Success) {
|
||||
onConnected(state.address)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.connect_to_server_title),
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
style = MaterialTheme.typography.h5,
|
||||
)
|
||||
Crossfade(
|
||||
targetState = serverSelectionMode,
|
||||
label = "Server selection mode",
|
||||
) { selectionType ->
|
||||
when (selectionType) {
|
||||
ServerSelectionMode.ADDRESS -> AddressSelection(
|
||||
text = hostname,
|
||||
errorText = when {
|
||||
externalError -> stringResource(R.string.connection_error_cannot_connect)
|
||||
else -> (checkUrlState as? CheckUrlState.Error)?.message
|
||||
},
|
||||
loading = checkUrlState is CheckUrlState.Pending,
|
||||
onTextChange = { value ->
|
||||
externalError = false
|
||||
checkUrlState = CheckUrlState.Unchecked
|
||||
hostname = value
|
||||
},
|
||||
onDiscoveryClick = {
|
||||
externalError = false
|
||||
keyboardController?.hide()
|
||||
serverSelectionMode = ServerSelectionMode.AUTO_DISCOVERY
|
||||
},
|
||||
onSubmit = {
|
||||
onSubmit()
|
||||
},
|
||||
)
|
||||
ServerSelectionMode.AUTO_DISCOVERY -> ServerDiscoveryList(
|
||||
serverSuggestions = serverSuggestions,
|
||||
onGoBack = {
|
||||
serverSelectionMode = ServerSelectionMode.ADDRESS
|
||||
},
|
||||
onSelectServer = { url ->
|
||||
hostname = url
|
||||
serverSelectionMode = ServerSelectionMode.ADDRESS
|
||||
onSubmit()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun AddressSelection(
|
||||
text: String,
|
||||
errorText: String?,
|
||||
loading: Boolean,
|
||||
onTextChange: (String) -> Unit,
|
||||
onDiscoveryClick: () -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
ServerUrlField(
|
||||
text = text,
|
||||
errorText = errorText,
|
||||
onTextChange = onTextChange,
|
||||
onSubmit = onSubmit,
|
||||
)
|
||||
AnimatedErrorText(errorText = errorText)
|
||||
if (!loading) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
StyledTextButton(
|
||||
text = stringResource(R.string.connect_button_text),
|
||||
enabled = text.isNotBlank(),
|
||||
onClick = onSubmit,
|
||||
)
|
||||
StyledTextButton(
|
||||
text = stringResource(R.string.choose_server_button_text),
|
||||
onClick = onDiscoveryClick,
|
||||
)
|
||||
} else {
|
||||
CenterRow {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun ServerUrlField(
|
||||
text: String,
|
||||
errorText: String?,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
.onKeyEvent { keyEvent ->
|
||||
when (keyEvent.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_ENTER -> {
|
||||
onSubmit()
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
label = {
|
||||
Text(text = stringResource(R.string.host_input_hint))
|
||||
},
|
||||
isError = errorText != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onGo = {
|
||||
onSubmit()
|
||||
},
|
||||
),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun AnimatedErrorText(
|
||||
errorText: String?,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = errorText != null,
|
||||
exit = ExitTransition.None,
|
||||
) {
|
||||
Text(
|
||||
text = errorText.orEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
color = MaterialTheme.colors.error,
|
||||
style = MaterialTheme.typography.caption,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun StyledTextButton(
|
||||
text: String,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.buttonColors(),
|
||||
) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
@Composable
|
||||
private fun ServerDiscoveryList(
|
||||
serverSuggestions: SnapshotStateList<ServerSuggestion>,
|
||||
onGoBack: () -> Unit,
|
||||
onSelectServer: (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onGoBack) {
|
||||
Icon(imageVector = Icons.Outlined.ArrowBack, contentDescription = null)
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp),
|
||||
text = stringResource(R.string.available_servers_title),
|
||||
)
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
color = MaterialTheme.colors.surface,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
),
|
||||
) {
|
||||
items(serverSuggestions) { server ->
|
||||
ServerDiscoveryItem(
|
||||
serverSuggestion = server,
|
||||
onClickServer = {
|
||||
onSelectServer(server.address)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Stable
|
||||
@Composable
|
||||
private fun ServerDiscoveryItem(
|
||||
serverSuggestion: ServerSuggestion,
|
||||
onClickServer: () -> Unit,
|
||||
) {
|
||||
ListItem(
|
||||
modifier = Modifier
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clickable(onClick = onClickServer),
|
||||
text = {
|
||||
Text(text = serverSuggestion.name)
|
||||
},
|
||||
secondaryText = {
|
||||
Text(text = serverSuggestion.address)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package org.jellyfin.mobile.ui.screens.connect
|
||||
|
||||
data class ServerSuggestion(
|
||||
val type: Type,
|
||||
val name: String,
|
||||
val address: String,
|
||||
/**
|
||||
* A timestamp for this suggestion, used for sorting.
|
||||
* For discovered servers, this should be the discovery time,
|
||||
* for saved servers, this should be the last used time.
|
||||
*/
|
||||
val timestamp: Long,
|
||||
) {
|
||||
enum class Type {
|
||||
DISCOVERED,
|
||||
SAVED,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package org.jellyfin.mobile.ui.state
|
||||
|
||||
sealed class CheckUrlState {
|
||||
object Unchecked : CheckUrlState()
|
||||
object Pending : CheckUrlState()
|
||||
class Success(val address: String) : CheckUrlState()
|
||||
class Error(val message: String?) : CheckUrlState()
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package org.jellyfin.mobile.ui.state
|
||||
|
||||
enum class ServerSelectionMode {
|
||||
ADDRESS,
|
||||
AUTO_DISCOVERY,
|
||||
}
|
||||
38
app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt
Normal file
38
app/src/main/java/org/jellyfin/mobile/ui/utils/AppTheme.kt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package org.jellyfin.mobile.ui.utils
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Shapes
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun AppTheme(content: @Composable () -> Unit) {
|
||||
val colors = remember {
|
||||
@Suppress("MagicNumber")
|
||||
darkColors(
|
||||
primary = Color(0xFF00A4DC),
|
||||
primaryVariant = Color(0xFF202020),
|
||||
background = Color(0xFF101010),
|
||||
surface = Color(0xFF363636),
|
||||
error = Color(0xFFCF6679),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onBackground = Color.White,
|
||||
onSurface = Color.White,
|
||||
onError = Color.White,
|
||||
)
|
||||
}
|
||||
MaterialTheme(
|
||||
colors = colors,
|
||||
shapes = Shapes(
|
||||
small = RoundedCornerShape(4.dp),
|
||||
medium = RoundedCornerShape(8.dp),
|
||||
large = RoundedCornerShape(0.dp),
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
22
app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt
Normal file
22
app/src/main/java/org/jellyfin/mobile/ui/utils/CenterRow.kt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package org.jellyfin.mobile.ui.utils
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
inline fun CenterRow(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) = Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(modifier),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Helper class to check the current Android version.
|
||||
*
|
||||
* Comparisons will be made against the current device's Android SDK version number in [Build.VERSION.SDK_INT].
|
||||
*
|
||||
* @see Build.VERSION.SDK_INT
|
||||
*/
|
||||
object AndroidVersion {
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 6 Marshmallow, API 23.
|
||||
*
|
||||
* @see Build.VERSION_CODES.M
|
||||
*/
|
||||
inline val isAtLeastM: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 7 Nougat, API 24.
|
||||
*
|
||||
* @see Build.VERSION_CODES.N
|
||||
*/
|
||||
inline val isAtLeastN: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 7.1 Nougat, API 25.
|
||||
*
|
||||
* @see Build.VERSION_CODES.N_MR1
|
||||
*/
|
||||
inline val isAtLeastNMR1: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 8 Oreo, API 26.
|
||||
*
|
||||
* @see Build.VERSION_CODES.O
|
||||
*/
|
||||
inline val isAtLeastO: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 9 Pie, API 28.
|
||||
*
|
||||
* @see Build.VERSION_CODES.P
|
||||
*/
|
||||
inline val isAtLeastP: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 10 Q, API 29.
|
||||
*
|
||||
* @see Build.VERSION_CODES.Q
|
||||
*/
|
||||
inline val isAtLeastQ: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 11 R, API 30.
|
||||
*
|
||||
* @see Build.VERSION_CODES.R
|
||||
*/
|
||||
inline val isAtLeastR: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 12 S, API 31.
|
||||
*
|
||||
* @see Build.VERSION_CODES.S
|
||||
*/
|
||||
inline val isAtLeastS: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 12 S V2, API 32.
|
||||
*
|
||||
* @see Build.VERSION_CODES.S_V2
|
||||
*/
|
||||
inline val isAtLeastSV2: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2
|
||||
|
||||
/**
|
||||
* Checks whether the current Android version is at least Android 13 Tiramisu, API 33.
|
||||
*
|
||||
* @see Build.VERSION_CODES.TIRAMISU
|
||||
*/
|
||||
inline val isAtLeastT: Boolean
|
||||
get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.jellyfin.mobile.MainActivity
|
||||
import org.jellyfin.mobile.utils.extensions.addFragment
|
||||
|
||||
/**
|
||||
* Additional hook for handling back presses in [Fragments][Fragment] (see [onInterceptBackPressed]).
|
||||
*
|
||||
* This hook is introduced since the AndroidX onBackPressedDispatcher system does not play well with the way we handle fragments:
|
||||
* The WebViewFragment always needs to be active, since it contains the state of the web interface.
|
||||
* To achieve this, we only add fragments (see [addFragment]) instead of doing the more common way
|
||||
* and replacing the current fragment.
|
||||
*
|
||||
* This keeps the WebViewFragment alive, but unless the new fragment registers its own onBackPressedCallback,
|
||||
* this also means that the WebViewFragment's onBackPressedCallbacks would still be the topmost dispatcher and therefore
|
||||
* would be called (see [OnBackPressedDispatcher.onBackPressed]).
|
||||
*
|
||||
* This wouldn't be a problem if there was some way for the WebViewFragment (or any other fragment that's active) to
|
||||
* know if it is the currently displayed fragment, since then it could deactivate its own onBackPressedCallback
|
||||
* and the next callback would be called instead.
|
||||
* The [MainActivity's][MainActivity] callback would then default to popping the backstack.
|
||||
*
|
||||
* There might be a way to implement this by using the backstack to determine if the current fragment is the topmost fragment,
|
||||
* but sadly it seems that this isn't possible in a non-hacky way (as in hardcoding names of backstack entries).
|
||||
*
|
||||
* Instead, the MainActivity determines the currently visible fragment,
|
||||
* and passes the back press event to it via the [onInterceptBackPressed] method.
|
||||
*/
|
||||
interface BackPressInterceptor {
|
||||
/**
|
||||
* Called when a back press is performed while this fragment is currently visible.
|
||||
*
|
||||
* @return `true` if the event was intercepted by the fragment,
|
||||
* `false` if the back press was not handled by the fragment.
|
||||
* The latter will result in a default action that closes the fragment
|
||||
* @see MainActivity.onBackPressedCallback
|
||||
*/
|
||||
fun onInterceptBackPressed(): Boolean = false
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.Manifest.permission.BLUETOOTH_CONNECT
|
||||
import android.app.AlertDialog
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.jellyfin.mobile.MainActivity
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class BluetoothPermissionHelper(
|
||||
private val activity: MainActivity,
|
||||
private val appPreferences: AppPreferences,
|
||||
) {
|
||||
/**
|
||||
* This is used to prevent the dialog from showing multiple times in a single session (activity creation).
|
||||
* Otherwise, the package manager and permission would need to be queried on every media event.
|
||||
*/
|
||||
private var wasDialogShowThisSession = false
|
||||
|
||||
@Suppress("ComplexCondition")
|
||||
suspend fun requestBluetoothPermissionIfNecessary() {
|
||||
// Check conditions by increasing complexity
|
||||
if (
|
||||
!AndroidVersion.isAtLeastS ||
|
||||
wasDialogShowThisSession ||
|
||||
activity.checkSelfPermission(BLUETOOTH_CONNECT) == PERMISSION_GRANTED ||
|
||||
appPreferences.ignoreBluetoothPermission ||
|
||||
!activity.packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
wasDialogShowThisSession = true
|
||||
|
||||
val shouldRequestPermission = suspendCancellableCoroutine { continuation ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.bluetooth_permission_title)
|
||||
.setMessage(R.string.bluetooth_permission_message)
|
||||
.setNegativeButton(android.R.string.cancel) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
continuation.resume(false)
|
||||
}
|
||||
.setPositiveButton(R.string.bluetooth_permission_continue) { dialog, _ ->
|
||||
dialog.dismiss()
|
||||
continuation.resume(true)
|
||||
}
|
||||
.setOnCancelListener {
|
||||
continuation.resume(false)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
if (!shouldRequestPermission) {
|
||||
appPreferences.ignoreBluetoothPermission = true
|
||||
return
|
||||
}
|
||||
|
||||
activity.requestPermission(BLUETOOTH_CONNECT) { requestPermissionsResult ->
|
||||
if (requestPermissionsResult[BLUETOOTH_CONNECT] == PERMISSION_GRANTED) {
|
||||
activity.toast(R.string.bluetooth_permission_granted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
class CombinedIntRange(private vararg val ranges: IntRange) {
|
||||
operator fun contains(value: Int) = ranges.any { range -> range.contains(value) }
|
||||
}
|
||||
148
app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
Normal file
148
app/src/main/java/org/jellyfin/mobile/utils/Constants.kt
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.media.session.PlaybackState
|
||||
import android.util.Rational
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
object Constants {
|
||||
// App Info
|
||||
const val APP_INFO_NAME = "Jellyfin Android"
|
||||
const val APP_INFO_VERSION: String = BuildConfig.VERSION_NAME
|
||||
|
||||
// Webapp constants
|
||||
const val MINIMUM_WEB_VIEW_VERSION = 80
|
||||
const val SHOW_PROGRESS_BAR_DELAY = 1000L
|
||||
const val INITIAL_CONNECTION_TIMEOUT = 10000L // 10 seconds
|
||||
val MAIN_BUNDLE_PATH_REGEX = Regex(""".*/main\.[^/\s]+\.bundle\.js""")
|
||||
const val CAST_SDK_PATH = "cast_sender.js"
|
||||
const val SESSION_CAPABILITIES_PATH = "sessions/capabilities/full"
|
||||
const val SERVICE_WORKER_PATH = "serviceworker.js"
|
||||
|
||||
const val FRAGMENT_CONNECT_EXTRA_ERROR = "org.jellyfin.mobile.intent.extra.ERROR"
|
||||
const val FRAGMENT_WEB_VIEW_EXTRA_SERVER = "org.jellyfin.mobile.intent.extra.SERVER"
|
||||
|
||||
// Preference keys
|
||||
const val PREF_SERVER_ID = "pref_server_id"
|
||||
const val PREF_USER_ID = "pref_user_id"
|
||||
const val PREF_INSTANCE_URL = "pref_instance_url"
|
||||
const val PREF_IGNORE_BATTERY_OPTIMIZATIONS = "pref_ignore_battery_optimizations"
|
||||
const val PREF_IGNORE_WEBVIEW_CHECKS = "pref_ignore_webview_checks"
|
||||
const val PREF_IGNORE_BLUETOOTH_PERMISSION = "pref_ignore_bluetooth_permission"
|
||||
const val PREF_DOWNLOAD_METHOD = "pref_download_method"
|
||||
const val PREF_MUSIC_NOTIFICATION_ALWAYS_DISMISSIBLE = "pref_music_notification_always_dismissible"
|
||||
const val PREF_VIDEO_PLAYER_TYPE = "pref_video_player_type"
|
||||
const val PREF_EXOPLAYER_START_LANDSCAPE_VIDEO_IN_LANDSCAPE = "pref_exoplayer_start_landscape_video_in_landscape"
|
||||
const val PREF_EXOPLAYER_ALLOW_SWIPE_GESTURES = "pref_exoplayer_allow_swipe_gestures"
|
||||
const val PREF_EXOPLAYER_REMEMBER_BRIGHTNESS = "pref_exoplayer_remember_brightness"
|
||||
const val PREF_EXOPLAYER_BRIGHTNESS = "pref_exoplayer_brightness"
|
||||
const val PREF_EXOPLAYER_ALLOW_BACKGROUND_AUDIO = "pref_exoplayer_allow_background_audio"
|
||||
const val PREF_EXOPLAYER_DIRECT_PLAY_ASS = "pref_exoplayer_direct_play_ass"
|
||||
const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app"
|
||||
const val PREF_SUBTITLE_STYLE = "pref_subtitle_style"
|
||||
const val PREF_DOWNLOAD_LOCATION = "pref_download_location"
|
||||
|
||||
// InputManager commands
|
||||
const val PLAYBACK_MANAGER_COMMAND_PLAY = "unpause"
|
||||
const val PLAYBACK_MANAGER_COMMAND_PAUSE = "pause"
|
||||
const val PLAYBACK_MANAGER_COMMAND_PREVIOUS = "previousTrack"
|
||||
const val PLAYBACK_MANAGER_COMMAND_NEXT = "nextTrack"
|
||||
const val PLAYBACK_MANAGER_COMMAND_REWIND = "rewind"
|
||||
const val PLAYBACK_MANAGER_COMMAND_FAST_FORWARD = "fastForward"
|
||||
const val PLAYBACK_MANAGER_COMMAND_STOP = "stop"
|
||||
const val PLAYBACK_MANAGER_COMMAND_VOL_UP = "volumeUp"
|
||||
const val PLAYBACK_MANAGER_COMMAND_VOL_DOWN = "volumeDown"
|
||||
|
||||
// Notification
|
||||
val PENDING_INTENT_FLAGS = PendingIntent.FLAG_UPDATE_CURRENT or when {
|
||||
AndroidVersion.isAtLeastM -> PendingIntent.FLAG_IMMUTABLE
|
||||
else -> 0
|
||||
}
|
||||
const val MEDIA_NOTIFICATION_CHANNEL_ID = "org.jellyfin.mobile.media.NOW_PLAYING"
|
||||
|
||||
// Music player constants
|
||||
const val SUPPORTED_MUSIC_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or
|
||||
PlaybackState.ACTION_PLAY or
|
||||
PlaybackState.ACTION_PAUSE or
|
||||
PlaybackState.ACTION_STOP or
|
||||
PlaybackState.ACTION_SKIP_TO_NEXT or
|
||||
PlaybackState.ACTION_SKIP_TO_PREVIOUS or
|
||||
PlaybackState.ACTION_SET_RATING
|
||||
const val MEDIA_PLAYER_NOTIFICATION_ID = 42
|
||||
const val REMOTE_PLAYER_CONTENT_INTENT_REQUEST_CODE = 100
|
||||
|
||||
// Music player intent actions
|
||||
const val ACTION_SHOW_PLAYER = "org.jellyfin.mobile.intent.action.SHOW_PLAYER"
|
||||
const val ACTION_PLAY = "org.jellyfin.mobile.intent.action.PLAY"
|
||||
const val ACTION_PAUSE = "org.jellyfin.mobile.intent.action.PAUSE"
|
||||
const val ACTION_REWIND = "org.jellyfin.mobile.intent.action.REWIND"
|
||||
const val ACTION_FAST_FORWARD = "org.jellyfin.mobile.intent.action.FAST_FORWARD"
|
||||
const val ACTION_PREVIOUS = "org.jellyfin.mobile.intent.action.PREVIOUS"
|
||||
const val ACTION_NEXT = "org.jellyfin.mobile.intent.action.NEXT"
|
||||
const val ACTION_STOP = "org.jellyfin.mobile.intent.action.STOP"
|
||||
const val ACTION_REPORT = "org.jellyfin.mobile.intent.action.REPORT"
|
||||
|
||||
// Music player intent extras
|
||||
const val EXTRA_PLAYER_ACTION = "action"
|
||||
const val EXTRA_ITEM_ID = "itemId"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_ARTIST = "artist"
|
||||
const val EXTRA_ALBUM = "album"
|
||||
const val EXTRA_IMAGE_URL = "imageUrl"
|
||||
const val EXTRA_POSITION = "position"
|
||||
const val EXTRA_DURATION = "duration"
|
||||
const val EXTRA_CAN_SEEK = "canSeek"
|
||||
const val EXTRA_IS_LOCAL_PLAYER = "isLocalPlayer"
|
||||
const val EXTRA_IS_PAUSED = "isPaused"
|
||||
|
||||
// Video player constants
|
||||
const val LANGUAGE_UNDEFINED = "und"
|
||||
const val TICKS_PER_MILLISECOND = 10000
|
||||
const val PLAYER_TIME_UPDATE_RATE = 10000L
|
||||
const val DEFAULT_CONTROLS_TIMEOUT_MS = 2500
|
||||
const val SWIPE_GESTURE_EXCLUSION_SIZE_VERTICAL = 64
|
||||
const val DEFAULT_CENTER_OVERLAY_TIMEOUT_MS = 250
|
||||
const val DISPLAY_PREFERENCES_ID_USER_SETTINGS = "usersettings"
|
||||
const val DISPLAY_PREFERENCES_CLIENT_EMBY = "emby"
|
||||
const val DISPLAY_PREFERENCES_SKIP_BACK_LENGTH = "skipBackLength"
|
||||
const val DISPLAY_PREFERENCES_SKIP_FORWARD_LENGTH = "skipForwardLength"
|
||||
const val DEFAULT_SEEK_TIME_MS = 10000L
|
||||
const val MAX_SKIP_TO_PREV_MS = 3000L
|
||||
const val DOUBLE_TAP_RIPPLE_DURATION_MS = 100L
|
||||
const val FULL_SWIPE_RANGE_SCREEN_RATIO = 0.66f
|
||||
const val SCREEN_BRIGHTNESS_MAX = 255
|
||||
const val ZOOM_SCALE_BASE = 1f
|
||||
const val ZOOM_SCALE_THRESHOLD = 0.01f
|
||||
val ASPECT_RATIO_16_9 = Rational(16, 9)
|
||||
val PIP_MIN_RATIONAL = Rational(100, 239)
|
||||
val PIP_MAX_RATIONAL = Rational(239, 100)
|
||||
const val SUPPORTED_VIDEO_PLAYER_PLAYBACK_ACTIONS: Long = PlaybackState.ACTION_PLAY_PAUSE or
|
||||
PlaybackState.ACTION_PLAY or
|
||||
PlaybackState.ACTION_PAUSE or
|
||||
PlaybackState.ACTION_SEEK_TO or
|
||||
PlaybackState.ACTION_REWIND or
|
||||
PlaybackState.ACTION_FAST_FORWARD or
|
||||
PlaybackState.ACTION_STOP
|
||||
const val VIDEO_PLAYER_NOTIFICATION_ID = 99
|
||||
|
||||
// Video player intent extras
|
||||
const val EXTRA_MEDIA_PLAY_OPTIONS = "org.jellyfin.mobile.MEDIA_PLAY_OPTIONS"
|
||||
|
||||
// External player result actions
|
||||
const val MPV_PLAYER_RESULT_ACTION = "is.xyz.mpv.MPVActivity.result"
|
||||
const val MX_PLAYER_RESULT_ACTION = "com.mxtech.intent.result.VIEW"
|
||||
const val VLC_PLAYER_RESULT_ACTION = "org.videolan.vlc.player.result"
|
||||
|
||||
// External player webapp events
|
||||
const val EVENT_ENDED = "Ended"
|
||||
const val EVENT_TIME_UPDATE = "TimeUpdate"
|
||||
const val EVENT_CANCELED = "Canceled"
|
||||
|
||||
// Orientation constants
|
||||
val ORIENTATION_PORTRAIT_RANGE = CombinedIntRange(340..360, 0..20)
|
||||
val ORIENTATION_LANDSCAPE_RANGE = CombinedIntRange(70..110, 250..290)
|
||||
|
||||
// Misc
|
||||
const val PERCENT_MAX = 100
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package org.jellyfin.mobile.utils;
|
||||
|
||||
import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_AND_ROAMING;
|
||||
import static org.jellyfin.mobile.utils.DownloadMethod.MOBILE_DATA;
|
||||
import static org.jellyfin.mobile.utils.DownloadMethod.WIFI_ONLY;
|
||||
|
||||
import androidx.annotation.IntDef;
|
||||
|
||||
@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING})
|
||||
public @interface DownloadMethod {
|
||||
int WIFI_ONLY = 0;
|
||||
int MOBILE_DATA = 1;
|
||||
int MOBILE_AND_ROAMING = 2;
|
||||
}
|
||||
11
app/src/main/java/org/jellyfin/mobile/utils/JellyTree.kt
Normal file
11
app/src/main/java/org/jellyfin/mobile/utils/JellyTree.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.util.Log
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
import timber.log.Timber
|
||||
|
||||
class JellyTree : Timber.DebugTree() {
|
||||
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
|
||||
if (BuildConfig.DEBUG || priority >= Log.INFO) super.log(priority, tag, message, t)
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt
Normal file
55
app/src/main/java/org/jellyfin/mobile/utils/LocaleUtils.kt
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.webkit.WebView
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
suspend fun WebView.initLocale(userId: String) {
|
||||
// Try to set locale via user settings
|
||||
val userSettings = suspendCoroutine { continuation ->
|
||||
evaluateJavascript("window.localStorage.getItem('$userId-language')") { result ->
|
||||
try {
|
||||
continuation.resume(JSONObject("{locale:$result}").getString("locale"))
|
||||
} catch (e: JSONException) {
|
||||
continuation.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (context.setLocale(userSettings)) return
|
||||
|
||||
// Fallback to device locale
|
||||
Timber.i("Couldn't acquire locale from config, keeping current")
|
||||
}
|
||||
|
||||
private fun Context.setLocale(localeString: String?): Boolean {
|
||||
if (localeString.isNullOrEmpty()) return false
|
||||
|
||||
val localeSplit = localeString.split('-')
|
||||
val locale = when (localeSplit.size) {
|
||||
1 -> Locale(localeString, "")
|
||||
2 -> Locale(localeSplit[0], localeSplit[1])
|
||||
else -> return false
|
||||
}
|
||||
|
||||
val configuration = resources.configuration
|
||||
if (locale != configuration.primaryLocale) {
|
||||
Locale.setDefault(locale)
|
||||
configuration.setLocale(locale)
|
||||
@Suppress("DEPRECATION")
|
||||
resources.updateConfiguration(configuration, resources.displayMetrics)
|
||||
|
||||
Timber.i("Updated locale from web: '$locale'")
|
||||
} // else: Locale is already applied
|
||||
return true
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val Configuration.primaryLocale: Locale
|
||||
get() = if (AndroidVersion.isAtLeastN) locales[0] else locale
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.ExoPlayer
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.analytics.AnalyticsCollector
|
||||
import org.jellyfin.mobile.player.source.JellyfinMediaSource
|
||||
import org.jellyfin.mobile.utils.extensions.width
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes as ExoPlayerAudioAttributes
|
||||
|
||||
inline fun MediaSession.applyDefaultLocalAudioAttributes(contentType: Int) {
|
||||
val audioAttributes = AudioAttributes.Builder().apply {
|
||||
setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
setContentType(contentType)
|
||||
if (AndroidVersion.isAtLeastQ) {
|
||||
setAllowedCapturePolicy(AudioAttributes.ALLOW_CAPTURE_BY_ALL)
|
||||
}
|
||||
}.build()
|
||||
setPlaybackToLocal(audioAttributes)
|
||||
}
|
||||
|
||||
fun JellyfinMediaSource.toMediaMetadata(): MediaMetadata = MediaMetadata.Builder().apply {
|
||||
putString(MediaMetadata.METADATA_KEY_MEDIA_ID, itemId.toString())
|
||||
putString(MediaMetadata.METADATA_KEY_TITLE, name)
|
||||
putLong(MediaMetadata.METADATA_KEY_DURATION, runTimeMs)
|
||||
}.build()
|
||||
|
||||
fun MediaSession.setPlaybackState(playbackState: Int, position: Long, playbackActions: Long) {
|
||||
val state = PlaybackState.Builder().apply {
|
||||
setState(playbackState, position, 1.0f)
|
||||
setActions(playbackActions)
|
||||
}.build()
|
||||
setPlaybackState(state)
|
||||
}
|
||||
|
||||
fun MediaSession.setPlaybackState(isPlaying: Boolean, position: Long, playbackActions: Long) {
|
||||
setPlaybackState(
|
||||
if (isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED,
|
||||
position,
|
||||
playbackActions,
|
||||
)
|
||||
}
|
||||
|
||||
fun MediaSession.setPlaybackState(player: Player, playbackActions: Long) {
|
||||
val playbackState = when (val playerState = player.playbackState) {
|
||||
Player.STATE_IDLE, Player.STATE_ENDED -> PlaybackState.STATE_NONE
|
||||
Player.STATE_READY -> if (player.isPlaying) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED
|
||||
Player.STATE_BUFFERING -> PlaybackState.STATE_BUFFERING
|
||||
else -> error("Invalid player playbackState $playerState")
|
||||
}
|
||||
setPlaybackState(playbackState, player.currentPosition, playbackActions)
|
||||
}
|
||||
|
||||
fun AudioManager.getVolumeRange(streamType: Int): IntRange {
|
||||
val minVolume = (if (AndroidVersion.isAtLeastP) getStreamMinVolume(streamType) else 0)
|
||||
val maxVolume = getStreamMaxVolume(streamType)
|
||||
return minVolume..maxVolume
|
||||
}
|
||||
|
||||
fun AudioManager.getVolumeLevelPercent(): Int {
|
||||
val stream = AudioManager.STREAM_MUSIC
|
||||
val volumeRange = getVolumeRange(stream)
|
||||
val currentVolume = getStreamVolume(stream)
|
||||
return (currentVolume - volumeRange.first) * Constants.PERCENT_MAX / volumeRange.width
|
||||
}
|
||||
|
||||
/**
|
||||
* Set ExoPlayer [ExoPlayerAudioAttributes], make ExoPlayer handle audio focus
|
||||
*/
|
||||
inline fun ExoPlayer.applyDefaultAudioAttributes(@C.AudioContentType contentType: Int) {
|
||||
val audioAttributes = ExoPlayerAudioAttributes.Builder()
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(contentType)
|
||||
.build()
|
||||
setAudioAttributes(audioAttributes, true)
|
||||
}
|
||||
|
||||
fun Player.seekToOffset(offsetMs: Long) {
|
||||
var positionMs = currentPosition + offsetMs
|
||||
val durationMs = duration
|
||||
if (durationMs != C.TIME_UNSET) {
|
||||
positionMs = positionMs.coerceAtMost(durationMs)
|
||||
}
|
||||
positionMs = positionMs.coerceAtLeast(0)
|
||||
seekTo(positionMs)
|
||||
}
|
||||
|
||||
fun Player.logTracks(analyticsCollector: AnalyticsCollector) {
|
||||
analyticsCollector.onTracksChanged(currentTracks)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.SparseArray
|
||||
import androidx.core.app.ActivityCompat
|
||||
import org.koin.android.ext.android.getKoin
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class PermissionRequestHelper {
|
||||
private val permissionRequests: SparseArray<PermissionRequestCallback> = SparseArray<PermissionRequestCallback>()
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
private var requestCode = AtomicInteger(50000) // start at a high number to prevent collisions
|
||||
|
||||
fun getRequestCode() = requestCode.getAndIncrement()
|
||||
|
||||
fun addCallback(requestCode: Int, callback: PermissionRequestCallback) {
|
||||
permissionRequests.put(requestCode, callback)
|
||||
}
|
||||
|
||||
fun handleRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
// Change to a map
|
||||
val permissionsMap = permissions
|
||||
.mapIndexed { index, permission ->
|
||||
Pair(permission, grantResults.getOrElse(index) { PackageManager.PERMISSION_DENIED })
|
||||
}
|
||||
.toMap()
|
||||
|
||||
// Execute and remove if it exists
|
||||
permissionRequests[requestCode]?.invoke(permissionsMap)
|
||||
permissionRequests.delete(requestCode)
|
||||
}
|
||||
}
|
||||
|
||||
typealias PermissionRequestCallback = (Map<String, Int>) -> Unit
|
||||
|
||||
fun Activity.requestPermission(vararg permissions: String, callback: PermissionRequestCallback) {
|
||||
val skipRequest = permissions.all { permission ->
|
||||
ActivityCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
if (skipRequest) {
|
||||
callback(permissions.associateWith { PackageManager.PERMISSION_GRANTED })
|
||||
} else {
|
||||
val helper = getKoin().get<PermissionRequestHelper>()
|
||||
val code = helper.getRequestCode()
|
||||
helper.addCallback(code, callback)
|
||||
ActivityCompat.requestPermissions(this, permissions, code)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.view.OrientationEventListener
|
||||
|
||||
/**
|
||||
* Listener that watches the current device orientation.
|
||||
* It makes sure that the orientation sensor can still be used (if enabled)
|
||||
* after toggling the orientation manually.
|
||||
*/
|
||||
class SmartOrientationListener(private val activity: Activity) : OrientationEventListener(activity) {
|
||||
override fun onOrientationChanged(orientation: Int) {
|
||||
if (!activity.isAutoRotateOn()) return
|
||||
|
||||
val isAtTarget = when (activity.requestedOrientation) {
|
||||
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT -> orientation in Constants.ORIENTATION_PORTRAIT_RANGE
|
||||
ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE -> orientation in Constants.ORIENTATION_LANDSCAPE_RANGE
|
||||
else -> false
|
||||
}
|
||||
if (isAtTarget) {
|
||||
// Reset to unspecified orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
}
|
||||
167
app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
Normal file
167
app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
import android.app.Activity
|
||||
import android.app.ActivityManager
|
||||
import android.app.AlertDialog
|
||||
import android.app.DownloadManager
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.Uri
|
||||
import android.os.Environment
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.provider.Settings.System.ACCELEROMETER_ROTATION
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.content.getSystemService
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.jellyfin.mobile.BuildConfig
|
||||
import org.jellyfin.mobile.MainActivity
|
||||
import org.jellyfin.mobile.R
|
||||
import org.jellyfin.mobile.app.AppPreferences
|
||||
import org.jellyfin.mobile.settings.ExternalPlayerPackage
|
||||
import org.jellyfin.mobile.webapp.WebViewFragment
|
||||
import org.koin.android.ext.android.get
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) {
|
||||
if (AndroidVersion.isAtLeastM) {
|
||||
val powerManager: PowerManager = requireContext().getSystemService(Activity.POWER_SERVICE) as PowerManager
|
||||
if (
|
||||
!appPreferences.ignoreBatteryOptimizations &&
|
||||
!powerManager.isIgnoringBatteryOptimizations(BuildConfig.APPLICATION_ID)
|
||||
) {
|
||||
Snackbar.make(rootView, R.string.battery_optimizations_message, Snackbar.LENGTH_INDEFINITE).apply {
|
||||
setAction(android.R.string.ok) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Timber.e(e)
|
||||
}
|
||||
|
||||
// Ignore after the user interacted with the snackbar at least once
|
||||
appPreferences.ignoreBatteryOptimizations = true
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun MainActivity.requestDownload(uri: Uri, title: String, filename: String) {
|
||||
val appPreferences: AppPreferences = get()
|
||||
|
||||
// Storage permission for downloads isn't necessary from Android 10 onwards
|
||||
if (!AndroidVersion.isAtLeastQ) {
|
||||
@Suppress("MagicNumber")
|
||||
val granted = withTimeout(2 * 60 * 1000 /* 2 minutes */) {
|
||||
suspendCoroutine { continuation ->
|
||||
requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult ->
|
||||
continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!granted) {
|
||||
toast(R.string.download_no_storage_permission)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation ->
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.network_title)
|
||||
.setMessage(R.string.network_message)
|
||||
.setPositiveButton(R.string.wifi_only) { _, _ ->
|
||||
val selectedDownloadMethod = DownloadMethod.WIFI_ONLY
|
||||
appPreferences.downloadMethod = selectedDownloadMethod
|
||||
continuation.resume(selectedDownloadMethod)
|
||||
}
|
||||
.setNegativeButton(R.string.mobile_data) { _, _ ->
|
||||
val selectedDownloadMethod = DownloadMethod.MOBILE_DATA
|
||||
appPreferences.downloadMethod = selectedDownloadMethod
|
||||
continuation.resume(selectedDownloadMethod)
|
||||
}
|
||||
.setNeutralButton(R.string.mobile_data_and_roaming) { _, _ ->
|
||||
val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING
|
||||
appPreferences.downloadMethod = selectedDownloadMethod
|
||||
continuation.resume(selectedDownloadMethod)
|
||||
}
|
||||
.setOnDismissListener {
|
||||
continuation.cancel(null)
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
val downloadRequest = DownloadManager.Request(uri)
|
||||
.setTitle(title)
|
||||
.setDescription(getString(R.string.downloading))
|
||||
.setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename)))
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
|
||||
downloadFile(downloadRequest, downloadMethod)
|
||||
}
|
||||
|
||||
private fun Context.downloadFile(request: DownloadManager.Request, @DownloadMethod downloadMethod: Int) {
|
||||
require(downloadMethod >= 0) { "Download method hasn't been set" }
|
||||
request.apply {
|
||||
setAllowedOverMetered(downloadMethod >= DownloadMethod.MOBILE_DATA)
|
||||
setAllowedOverRoaming(downloadMethod == DownloadMethod.MOBILE_AND_ROAMING)
|
||||
}
|
||||
getSystemService<DownloadManager>()?.enqueue(request)
|
||||
}
|
||||
|
||||
fun Activity.isAutoRotateOn() = Settings.System.getInt(contentResolver, ACCELEROMETER_ROTATION, 0) == 1
|
||||
|
||||
fun PackageManager.isPackageInstalled(@ExternalPlayerPackage packageName: String) = try {
|
||||
packageName.isNotEmpty() && getApplicationInfo(packageName, 0).enabled
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
|
||||
fun Context.createMediaNotificationChannel(notificationManager: NotificationManager) {
|
||||
if (AndroidVersion.isAtLeastO) {
|
||||
val notificationChannel = NotificationChannel(
|
||||
Constants.MEDIA_NOTIFICATION_CHANNEL_ID,
|
||||
getString(R.string.app_name),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Media notifications"
|
||||
}
|
||||
notificationManager.createNotificationChannel(notificationChannel)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getDownloadsPaths(): List<String> = ArrayList<String>().apply {
|
||||
for (directory in getExternalFilesDirs(null)) {
|
||||
// Ignore currently unavailable shared storage
|
||||
if (directory == null) continue
|
||||
|
||||
val path = directory.absolutePath
|
||||
val androidFolderIndex = path.indexOf("/Android")
|
||||
if (androidFolderIndex == -1) continue
|
||||
|
||||
val storageDirectory = File(path.substring(0, androidFolderIndex))
|
||||
if (storageDirectory.isDirectory) {
|
||||
add(File(storageDirectory, Environment.DIRECTORY_DOWNLOADS).absolutePath)
|
||||
}
|
||||
}
|
||||
if (isEmpty()) {
|
||||
add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val Context.isLowRamDevice: Boolean
|
||||
get() = getSystemService<ActivityManager>()!!.isLowRamDevice
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package org.jellyfin.mobile.utils
|
||||
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.source.TrackGroup
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector
|
||||
import com.google.android.exoplayer2.trackselection.TrackSelectionOverride
|
||||
|
||||
/**
|
||||
* Select the [trackGroup] of the specified [type] and ensure the type is enabled.
|
||||
*
|
||||
* @param type One of the TRACK_TYPE_* constants defined in [C].
|
||||
* @param trackGroup the [TrackGroup] to select.
|
||||
*/
|
||||
fun DefaultTrackSelector.selectTrackByTypeAndGroup(type: Int, trackGroup: TrackGroup): Boolean {
|
||||
val parameters = with(buildUponParameters()) {
|
||||
clearOverridesOfType(type)
|
||||
addOverride(TrackSelectionOverride(trackGroup, 0))
|
||||
setTrackTypeDisabled(type, false)
|
||||
}
|
||||
setParameters(parameters)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear selection overrides for all renderers of the specified [type] and disable them.
|
||||
*
|
||||
* @param type One of the TRACK_TYPE_* constants defined in [C].
|
||||
*/
|
||||
fun DefaultTrackSelector.clearSelectionAndDisableRendererByType(type: Int): Boolean {
|
||||
val parameters = with(buildUponParameters()) {
|
||||
clearOverridesOfType(type)
|
||||
setTrackTypeDisabled(type, true)
|
||||
}
|
||||
setParameters(parameters)
|
||||
return true
|
||||
}
|
||||
70
app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt
Normal file
70
app/src/main/java/org/jellyfin/mobile/utils/UIExtensions.kt
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
@file:Suppress("unused", "NOTHING_TO_INLINE")
|
||||
|
||||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.view.ContextThemeWrapper
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup.MarginLayoutParams
|
||||
import android.view.Window
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.annotation.StyleRes
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.core.view.updateLayoutParams
|
||||
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
inline fun Context.toast(@StringRes text: Int, duration: Int = Toast.LENGTH_SHORT) =
|
||||
Toast.makeText(this, text, duration).show()
|
||||
|
||||
inline fun Context.toast(text: CharSequence, duration: Int = Toast.LENGTH_SHORT) =
|
||||
Toast.makeText(this, text, duration).show()
|
||||
|
||||
inline fun LifecycleOwner.runOnUiThread(noinline block: suspend CoroutineScope.() -> Unit) {
|
||||
lifecycleScope.launch(Dispatchers.Main, block = block)
|
||||
}
|
||||
|
||||
fun LayoutInflater.withThemedContext(context: Context, @StyleRes style: Int): LayoutInflater {
|
||||
return cloneInContext(ContextThemeWrapper(context, style))
|
||||
}
|
||||
|
||||
fun View.applyWindowInsetsAsMargins() {
|
||||
ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsets ->
|
||||
val insets = windowInsets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
updateLayoutParams<MarginLayoutParams> {
|
||||
setMargins(insets.left, insets.top, insets.right, insets.bottom)
|
||||
}
|
||||
windowInsets
|
||||
}
|
||||
}
|
||||
|
||||
fun View.fadeIn() {
|
||||
alpha = 0f
|
||||
isVisible = true
|
||||
animate().apply {
|
||||
alpha(1f)
|
||||
@Suppress("MagicNumber")
|
||||
duration = 300L
|
||||
interpolator = LinearOutSlowInInterpolator()
|
||||
withLayer()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Resources.dip(px: Int) = (px * displayMetrics.density).toInt()
|
||||
|
||||
inline var Window.brightness: Float
|
||||
get() = attributes.screenBrightness
|
||||
set(value) {
|
||||
attributes = attributes.apply {
|
||||
screenBrightness = value
|
||||
}
|
||||
}
|
||||
120
app/src/main/java/org/jellyfin/mobile/utils/WebViewUtils.kt
Normal file
120
app/src/main/java/org/jellyfin/mobile/utils/WebViewUtils.kt
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Taken and adapted from https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/util/system/WebViewUtil.kt
|
||||
*
|
||||
* Copyright 2015 Javier Tomás
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.jellyfin.mobile.utils
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.webkit.CookieManager
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import androidx.webkit.ServiceWorkerClientCompat
|
||||
import androidx.webkit.ServiceWorkerControllerCompat
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewFeature
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import timber.log.Timber
|
||||
import java.util.Locale
|
||||
|
||||
fun Context.isWebViewSupported(): Boolean {
|
||||
@Suppress("TooGenericExceptionCaught")
|
||||
try {
|
||||
// May throw android.webkit.WebViewFactory$MissingWebViewPackageException if WebView is not installed
|
||||
CookieManager.getInstance()
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e)
|
||||
return false
|
||||
}
|
||||
|
||||
return packageManager.hasSystemFeature(PackageManager.FEATURE_WEBVIEW)
|
||||
}
|
||||
|
||||
fun WebView.isOutdated(): Boolean =
|
||||
getWebViewMajorVersion() < Constants.MINIMUM_WEB_VIEW_VERSION
|
||||
|
||||
private fun WebView.getWebViewMajorVersion(): Int {
|
||||
val userAgent = getDefaultUserAgentString()
|
||||
val version = """.*Chrome/(\d+)\..*""".toRegex().matchEntire(userAgent)?.let { match ->
|
||||
match.groupValues.getOrNull(1)?.toInt()
|
||||
} ?: 0
|
||||
|
||||
Timber.i("WebView user agent is $userAgent, detected version is $version")
|
||||
|
||||
return version
|
||||
}
|
||||
|
||||
// Based on https://stackoverflow.com/a/29218966
|
||||
private fun WebView.getDefaultUserAgentString(): String {
|
||||
val originalUA: String = settings.userAgentString
|
||||
|
||||
// Next call to getUserAgentString() will get us the default
|
||||
settings.userAgentString = null
|
||||
val defaultUserAgentString = settings.userAgentString
|
||||
|
||||
// Revert to original UA string
|
||||
settings.userAgentString = originalUA
|
||||
|
||||
return defaultUserAgentString
|
||||
}
|
||||
|
||||
/**
|
||||
* Workaround for service worker breaking script injections
|
||||
*/
|
||||
fun enableServiceWorkerWorkaround() {
|
||||
if (!WebViewFeature.isFeatureSupported(WebViewFeature.SERVICE_WORKER_BASIC_USAGE)) {
|
||||
return
|
||||
}
|
||||
|
||||
val serviceWorkerClient = object : ServiceWorkerClientCompat() {
|
||||
override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? {
|
||||
val path = request.url.path?.lowercase(Locale.ROOT) ?: return null
|
||||
return when {
|
||||
path.endsWith(Constants.SERVICE_WORKER_PATH) -> {
|
||||
WebResourceResponse("application/javascript", "utf-8", null).apply {
|
||||
with(HttpStatusCode.NotFound) { setStatusCodeAndReasonPhrase(value, description) }
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ServiceWorkerControllerCompat.getInstance().setServiceWorkerClient(serviceWorkerClient)
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
fun WebSettings.applyDefault() {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the requested file from the application's assets directory.
|
||||
*
|
||||
* On some devices Android doesn't set the JavaScript MIME type,
|
||||
* thus manually set it to "application/javascript" where applicable.
|
||||
*
|
||||
* @see WebViewAssetLoader.AssetsPathHandler.handle
|
||||
*/
|
||||
fun WebViewAssetLoader.AssetsPathHandler.inject(path: String): WebResourceResponse? = handle(path)?.apply {
|
||||
if (path.endsWith(".js", ignoreCase = true)) {
|
||||
mimeType = "application/javascript"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue