diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml index 1f35c259..309c6b59 100644 --- a/.github/actions/native-build/action.yml +++ b/.github/actions/native-build/action.yml @@ -15,6 +15,25 @@ runs: path: | app/libs key: ${{ runner.os }}-ffmpeg-${{ hashFiles('**/libs.versions.toml', '**/scripts/ffmpeg/build_ffmpeg_decoder.sh') }} + - name: Load libmpv module cache + id: cache_libmpv_module + if: ${{ inputs.cache }} + uses: actions/cache/restore@v4 + with: + path: | + app/src/main/libs + key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} + - name: Install dependencies + if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' || steps.cache_libmpv_module.outputs.cache-hit != 'true' + shell: bash + run: | + python -m pip install --upgrade pip + pip install jsonschema jinja2 + sudo apt update + sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson nasm + + +# ffmpeg - name: Build ffmpeg decoder id: ffmpeg-decoder if: steps.cache_ffmpeg_module.outputs.cache-hit != 'true' @@ -30,23 +49,7 @@ runs: app/libs key: ${{ steps.cache_ffmpeg_module.outputs.cache-primary-key }} - - name: Load libmpv module cache - id: cache_libmpv_module - if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 - with: - path: | - app/src/main/libs - key: ${{ runner.os }}-libmpv-${{ hashFiles('**/scripts/mpv/*.sh', '**/scripts/mpv/include/*', '**/scripts/mpv/scripts/*', 'app/src/main/jni/*') }} - - - name: Install dependencies - if: steps.cache_libmpv_module.outputs.cache-hit != 'true' - shell: bash - run: | - python -m pip install --upgrade pip - pip install jsonschema jinja2 - sudo apt update - sudo apt install -y build-essential autoconf pkg-config libtool ninja-build unzip wget meson +# libmpv - name: Get libmpv dependencies if: steps.cache_libmpv_module.outputs.cache-hit != 'true' shell: bash diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d81c5e18..56513c4b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -43,7 +43,7 @@ jobs: - name: Build app id: buildapp run: | - ./gradlew clean assembleDebug --no-daemon + ./gradlew clean assembleDebug testDebugUnitTest --no-daemon apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - name: Tar build dirs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8ae975b..66d09bf8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,11 +15,13 @@ plugins { alias(libs.plugins.protobuf) alias(libs.plugins.kotlin.plugin.serialization) alias(libs.plugins.aboutLibraries) + alias(libs.plugins.openapi.generator) } val isCI = if (System.getenv("CI") != null) System.getenv("CI").toBoolean() else false val shouldSign = isCI && System.getenv("KEY_ALIAS") != null val ffmpegModuleExists = project.file("libs/lib-decoder-ffmpeg-release.aar").exists() +val av1ModuleExists = project.file("libs/lib-decoder-av1-release.aar").exists() val gitTags = providers @@ -145,6 +147,12 @@ android { isUniversalApk = true } } + + sourceSets { + getByName("main") { + kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + } + } } protobuf { @@ -176,6 +184,33 @@ aboutLibraries { } } +openApiGenerate { + generatorName.set("kotlin") + inputSpec.set("$projectDir/src/main/seerr/seerr-api.yml") + templateDir.set("$projectDir/src/main/seerr/templates") + outputDir.set("$buildDir/generated/seerr_api") + apiPackage.set("com.github.damontecres.wholphin.api.seerr") + modelPackage.set("com.github.damontecres.wholphin.api.seerr.model") + groupId.set("com.github.damontecres.wholphin.api.seerr") + id.set("seerr-api") + packageName.set("com.github.damontecres.wholphin.api.seerr") + additionalProperties.apply { + put("serializationLibrary", "kotlinx_serialization") + put("sortModelPropertiesByRequiredFlag", true) + put("sortParamsByRequiredFlag", true) + put("useCoroutines", true) + put("enumPropertyNaming", "UPPERCASE") + put("modelMutable", false) + + // Note: this is only for downloading files, so it's not necessary to enable + put("supportAndroidApiLevel25AndBelow", false) + } +} + +tasks.named("preBuild") { + dependsOn.add(tasks.named("openApiGenerate")) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -191,10 +226,15 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) + implementation(libs.androidx.tvprovider) + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.exoplayer.hls) + implementation(libs.androidx.media3.exoplayer.dash) implementation(libs.androidx.media3.ui) implementation(libs.androidx.media3.ui.compose) implementation(libs.ass.media) @@ -228,6 +268,7 @@ dependencies { implementation(libs.androidx.palette.ktx) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) + ksp(libs.androidx.hilt.compiler) implementation(libs.timber) implementation(libs.slf4j2.timber) @@ -241,6 +282,8 @@ dependencies { implementation(libs.acra.limiter) compileOnly(libs.auto.service.annotations) ksp(libs.auto.service.ksp) + implementation(platform(libs.okhttp.bom)) + implementation(libs.okhttp) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) @@ -250,4 +293,11 @@ dependencies { if (ffmpegModuleExists || isCI) { implementation(files("libs/lib-decoder-ffmpeg-release.aar")) } + if (av1ModuleExists || isCI) { + implementation(files("libs/lib-decoder-av1-release.aar")) + } + + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + testImplementation(libs.robolectric) } diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json new file mode 100644 index 00000000..9c365430 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/20.json @@ -0,0 +1,549 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "dea51adcc724179afa0174d775f97480", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "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, 'dea51adcc724179afa0174d775f97480')" + ] + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d84299e..ccc0f74e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -11,6 +12,7 @@ + + + + + + + + + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> @@ -53,6 +63,10 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/provider_paths" /> + diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 2e65a8c7..fb3e88ed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin +import android.content.Intent +import android.content.res.Configuration import android.os.Bundle import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -13,13 +15,17 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator @@ -35,19 +41,25 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.DatePlayedInvalidationService import com.github.damontecres.wholphin.services.DeviceProfileService import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setup.SwitchServerContent import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme @@ -55,11 +67,9 @@ import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import javax.inject.Inject @@ -96,6 +106,20 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var refreshRateService: RefreshRateService + @Inject + lateinit var userSwitchListener: UserSwitchListener + + @Inject + lateinit var tvProviderSchedulerService: TvProviderSchedulerService + + // Note: unused but injected to ensure it is created + @Inject + lateinit var serverEventListener: ServerEventListener + + // Note: unused but injected to ensure it is created + @Inject + lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -106,15 +130,16 @@ class MainActivity : AppCompatActivity() { if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - refreshRateService.refreshRateMode.observe(this) { mode -> + refreshRateService.refreshRateMode.observe(this) { modeId -> // Listen for refresh rate changes val attrs = window.attributes - if (attrs.preferredDisplayModeId != mode.modeId) { - Timber.d("Switch preferredRefreshRate to %s", mode.refreshRate) - window.attributes = attrs.apply { preferredRefreshRate = mode.refreshRate } + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } } } viewModel.appStart() + val requestedDestination = this.intent?.let(::extractDestination) setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) appPreferences?.let { appPreferences -> @@ -211,13 +236,37 @@ class MainActivity : AppCompatActivity() { remember(appPreferences) { UserPreferences(appPreferences) } - ApplicationContent( - user = current.user, - server = current.server, - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!preferences.appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = + requestedDestination + ?: Destination.Home(), + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } } } } @@ -233,7 +282,7 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() - Timber.i("onResume") + Timber.d("onResume") lifecycleScope.launchIO { appUpgradeHandler.run() } @@ -241,7 +290,7 @@ class MainActivity : AppCompatActivity() { override fun onRestart() { super.onRestart() - Timber.i("onRestart") + Timber.d("onRestart") viewModel.appStart() // val signInAutomatically = // runBlocking { userPreferencesDataStore.data.firstOrNull()?.signInAutomatically } ?: true @@ -253,35 +302,89 @@ class MainActivity : AppCompatActivity() { // } } -// override fun onStop() { -// super.onStop() -// Timber.i("onStop") -// } -// -// override fun onPause() { -// super.onPause() -// Timber.i("onPause") -// } -// -// override fun onStart() { -// super.onStart() -// Timber.i("onStart") -// } -// -// override fun onSaveInstanceState(outState: Bundle) { -// super.onSaveInstanceState(outState) -// Timber.i("onSaveInstanceState") -// } -// -// override fun onRestoreInstanceState(savedInstanceState: Bundle) { -// super.onRestoreInstanceState(savedInstanceState) -// Timber.i("onRestoreInstanceState") -// } -// -// override fun onDestroy() { -// super.onDestroy() -// Timber.i("onDestroy") -// } + override fun onStop() { + super.onStop() + Timber.d("onStop") + tvProviderSchedulerService.launchOneTimeRefresh() + } + + override fun onPause() { + super.onPause() + Timber.d("onPause") + } + + override fun onStart() { + super.onStart() + Timber.d("onStart") + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Timber.d("onSaveInstanceState") + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + Timber.d("onRestoreInstanceState") + } + + override fun onDestroy() { + super.onDestroy() + Timber.d("onDestroy") + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + Timber.d("onConfigurationChanged") + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + Timber.v("onNewIntent") + extractDestination(intent)?.let { + navigationManager.replace(it) + } + } + + private fun extractDestination(intent: Intent): Destination? = + intent.let { + val itemId = + it.getStringExtra(INTENT_ITEM_ID)?.toUUIDOrNull() + val type = + it.getStringExtra(INTENT_ITEM_TYPE)?.let(BaseItemKind::fromNameOrNull) + if (itemId != null && type != null) { + val seriesId = it.getStringExtra(INTENT_SERIES_ID)?.toUUIDOrNull() + val seasonId = it.getStringExtra(INTENT_SEASON_ID)?.toUUIDOrNull() + val episodeNumber = it.getIntExtra(INTENT_EPISODE_NUMBER, -1) + val seasonNumber = it.getIntExtra(INTENT_SEASON_NUMBER, -1) + if (seriesId != null && seasonId != null && episodeNumber >= 0 && seasonNumber >= 0) { + Destination.SeriesOverview( + itemId = seriesId, + type = BaseItemKind.SERIES, + seasonEpisode = + SeasonEpisodeIds( + seasonId = seasonId, + seasonNumber = seasonNumber, + episodeId = itemId, + episodeNumber = episodeNumber, + ), + ) + } else { + Destination.MediaItem(itemId, type) + } + } else { + null + } + } + + companion object { + const val INTENT_ITEM_ID = "itemId" + const val INTENT_ITEM_TYPE = "itemType" + const val INTENT_SERIES_ID = "seriesId" + const val INTENT_EPISODE_NUMBER = "epNum" + const val INTENT_SEASON_NUMBER = "seaNum" + const val INTENT_SEASON_ID = "seaId" + } } @HiltViewModel @@ -295,40 +398,42 @@ class MainActivityViewModel private val backdropService: BackdropService, ) : ViewModel() { fun appStart() { - viewModelScope.launch { - val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() - if (prefs.signInAutomatically) { - val current = - withContext(Dispatchers.IO) { + viewModelScope.launchIO { + try { + val prefs = + preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + if (prefs.signInAutomatically) { + val current = serverRepository.restoreSession( prefs.currentServerId?.toUUIDOrNull(), prefs.currentUserId?.toUUIDOrNull(), ) - } - if (current != null) { - // Restored - navigationManager.navigateTo(SetupDestination.AppContent(current)) - } else { - // Did not restore - navigationManager.navigateTo(SetupDestination.ServerList) - } - } else { - navigationManager.navigateTo(SetupDestination.Loading) - backdropService.clearBackdrop() - val currentServerId = prefs.currentServerId?.toUUIDOrNull() - if (currentServerId != null) { - val currentServer = - withContext(Dispatchers.IO) { - serverRepository.serverDao.getServer(currentServerId)?.server - } - if (currentServer != null) { - navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + if (current != null) { + // Restored + navigationManager.navigateTo(SetupDestination.AppContent(current)) } else { + // Did not restore navigationManager.navigateTo(SetupDestination.ServerList) } } else { - navigationManager.navigateTo(SetupDestination.ServerList) + navigationManager.navigateTo(SetupDestination.Loading) + backdropService.clearBackdrop() + val currentServerId = prefs.currentServerId?.toUUIDOrNull() + if (currentServerId != null) { + val currentServer = + serverRepository.serverDao.getServer(currentServerId)?.server + if (currentServer != null) { + navigationManager.navigateTo(SetupDestination.UserList(currentServer)) + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } + } else { + navigationManager.navigateTo(SetupDestination.ServerList) + } } + } catch (ex: Exception) { + Timber.e(ex, "Error during appStart") + navigationManager.navigateTo(SetupDestination.ServerList) } } viewModelScope.launchIO { diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 3368091a..ae4b5d28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -7,6 +7,8 @@ import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration import dagger.hilt.android.HiltAndroidApp import org.acra.ACRA import org.acra.ReportField @@ -14,10 +16,13 @@ import org.acra.config.dialog import org.acra.data.StringFormat import org.acra.ktx.initAcra import timber.log.Timber +import javax.inject.Inject @OptIn(ExperimentalComposeRuntimeApi::class) @HiltAndroidApp -class WholphinApplication : Application() { +class WholphinApplication : + Application(), + Configuration.Provider { init { instance = this @@ -94,6 +99,16 @@ class WholphinApplication : Application() { ACRA.errorReporter.putCustomData("SDK_INT", Build.VERSION.SDK_INT.toString()) } + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override val workManagerConfiguration: Configuration + get() = + Configuration + .Builder() + .setWorkerFactory(workerFactory) + .build() + companion object { lateinit var instance: WholphinApplication private set diff --git a/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt new file mode 100644 index 00000000..4a76a00c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/api/seerr/SeerrApiClient.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.api.seerr + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.Call +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import timber.log.Timber + +class SeerrApiClient( + val baseUrl: String, + private val apiKey: String?, + okHttpClient: OkHttpClient, +) { + private val cookieJar = SeerrCookieJar() + + private val client = + okHttpClient + .newBuilder() + .cookieJar(cookieJar) + .addInterceptor { + Timber.d("SeerrApiClient: ${it.request().method} ${it.request().url}") + it.proceed( + it + .request() + .newBuilder() + .apply { + if (apiKey.isNotNullOrBlank()) header("X-Api-Key", apiKey) + }.build(), + ) + }.build() + + val hasValidCredentials: Boolean + get() = + apiKey.isNotNullOrBlank() || + cookieJar.hasValidCredentials(baseUrl) + + private fun create(initializer: (String, Call.Factory) -> T): Lazy = + lazy { + initializer.invoke(baseUrl, client) + } + + val authApi by create(::AuthApi) + val blacklistApi by create(::BlacklistApi) + val collectionApi by create(::CollectionApi) + val issueApi by create(::IssueApi) + val mediaApi by create(::MediaApi) + val moviesApi by create(::MoviesApi) + val otherApi by create(::OtherApi) + val overrideruleApi by create(::OverrideruleApi) + val personApi by create(::PersonApi) + val publicApi by create(::PublicApi) + val requestApi by create(::RequestApi) + val searchApi by create(::SearchApi) + val serviceApi by create(::ServiceApi) + val settingsApi by create(::SettingsApi) + val tmdbApi by create(::TmdbApi) + val tvApi by create(::TvApi) + val usersApi by create(::UsersApi) + val watchlistApi by create(::WatchlistApi) +} + +private class SeerrCookieJar : CookieJar { + private val cookies = mutableMapOf>() + + override fun saveFromResponse( + url: HttpUrl, + cookies: List, + ) { + cookies + .filter { it.name == "connect.sid" } + .groupBy { it.domain } + .forEach { (domain, cookies) -> + this.cookies[domain] = cookies + } + } + + override fun loadForRequest(url: HttpUrl): List = this.cookies[url.host].orEmpty() + + fun hasValidCredentials(baseUrl: String): Boolean = + baseUrl.toHttpUrlOrNull()?.host?.let { domain -> + cookies[domain]?.any { it.expiresAt > System.currentTimeMillis() } + } == true +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index fe6e7620..19e33616 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser import com.github.damontecres.wholphin.ui.components.ViewOptions import kotlinx.serialization.json.Json import org.jellyfin.sdk.model.api.ItemSortBy @@ -32,8 +34,10 @@ import java.util.UUID LibraryDisplayInfo::class, PlaybackLanguageChoice::class, ItemTrackModification::class, + SeerrServer::class, + SeerrUser::class, ], - version = 12, + version = 20, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -45,6 +49,7 @@ import java.util.UUID AutoMigration(9, 10), AutoMigration(10, 11), AutoMigration(11, 12), + AutoMigration(12, 20), ], ) @TypeConverters(Converters::class) @@ -58,6 +63,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun libraryDisplayInfoDao(): LibraryDisplayInfoDao abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao + + abstract fun seerrServerDao(): SeerrServerDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt index dedbb8a7..a3880d30 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackDao.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -11,22 +12,25 @@ import java.util.UUID @Dao interface ItemPlaybackDao { - fun getItem( + suspend fun getItem( user: JellyfinUser, itemId: UUID, ): ItemPlayback? = getItem(user.rowId, itemId) @Query("SELECT * from ItemPlayback WHERE userId=:userId AND itemId=:itemId") - fun getItem( + suspend fun getItem( userId: Int, itemId: UUID, ): ItemPlayback? @Insert(onConflict = OnConflictStrategy.REPLACE) - fun saveItem(item: ItemPlayback): Long + suspend fun saveItem(item: ItemPlayback): Long + + @Delete + suspend fun deleteItem(item: ItemPlayback) @Query("SELECT * from ItemPlayback WHERE userId=:userId") - fun getItems(userId: Int): List + suspend fun getItems(userId: Int): List @Query("SELECT * FROM ItemTrackModification WHERE userId=:userId AND itemId=:itemId") suspend fun getTrackModifications( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt index 00bc0fb7..3f8dc198 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ItemPlaybackRepository.kt @@ -25,6 +25,7 @@ class ItemPlaybackRepository constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, + private val playbackLanguageChoiceDao: PlaybackLanguageChoiceDao, private val streamChoiceService: StreamChoiceService, ) { suspend fun getSelectedTracks( @@ -61,7 +62,7 @@ class ItemPlaybackRepository ) val subtitleStream = streamChoiceService.chooseSubtitleStream( - audioStream = audioStream, + audioStreamLang = audioStream?.language, candidates = source.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } @@ -132,7 +133,7 @@ class ItemPlaybackRepository Timber.v("Saving track selection %s", toSave) toSave = saveItemPlayback(toSave) val seriesId = item.data.seriesId - if (seriesId != null && trackIndex != TrackIndex.UNSPECIFIED) { + if (seriesId != null && (trackIndex >= 0 || trackIndex == TrackIndex.DISABLED)) { if (type == MediaStreamType.AUDIO) { val stream = source.mediaStreams?.first { it.index == trackIndex } if (stream?.language != null) { @@ -146,7 +147,7 @@ class ItemPlaybackRepository subtitlesDisabled = true, ) } else { - val stream = source.mediaStreams?.first { it.index == trackIndex } + val stream = source.mediaStreams?.firstOrNull { it.index == trackIndex } if (stream?.language != null) { streamChoiceService.updateSubtitles( item.data, @@ -204,6 +205,18 @@ class ItemPlaybackRepository ) } } + + suspend fun deleteChosenStreams(chosenStreams: ChosenStreams?) { + Timber.d("deleteChosenStreams: %s", chosenStreams) + chosenStreams?.plc?.let { + Timber.d("Deleting %s", it) + playbackLanguageChoiceDao.delete(it) + } + chosenStreams?.itemPlayback?.let { + Timber.d("Deleting %s", it) + itemPlaybackDao.deleteItem(it) + } + } } data class ChosenStreams( diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt index ba070116..2312c62c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt @@ -4,11 +4,13 @@ import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.first import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -24,6 +26,7 @@ class NavDrawerItemRepository private val api: ApiClient, private val serverRepository: ServerRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, ) { suspend fun getNavDrawerItems(): List { val user = serverRepository.currentUser.value @@ -46,7 +49,13 @@ class NavDrawerItemRepository setOf() } - val builtins = listOf(NavDrawerItem.Favorites) + val builtins = + if (seerrServerRepository.active.first()) { + listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + } else { + listOf(NavDrawerItem.Favorites) + } + val libraries = userViews .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt index e268a08c..5f8c16e6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackLanguageChoiceDao.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.data import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query @@ -17,4 +18,7 @@ interface PlaybackLanguageChoiceDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(plc: PlaybackLanguageChoice): Long + + @Delete + fun delete(plc: PlaybackLanguageChoice) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt new file mode 100644 index 00000000..59f5d9ea --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrServerUsers +import com.github.damontecres.wholphin.data.model.SeerrUser + +@Dao +interface SeerrServerDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun addServer(server: SeerrServer): Long + + @Update + suspend fun updateServer(server: SeerrServer): Int + + @Transaction + suspend fun addOrUpdateServer(server: SeerrServer) { + val result = addServer(server) + if (result == -1L) { + updateServer(server) + } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addUser(user: SeerrUser): Long + + suspend fun updateUser(user: SeerrUser) = addUser(user) + + @Query("SELECT * FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUser( + serverId: Int, + jellyfinUserRowId: Int, + ): SeerrUser? + + @Query("SELECT * FROM seerr_users WHERE jellyfinUserRowId = :jellyfinUserRowId") + suspend fun getUsersByJellyfinUser(jellyfinUserRowId: Int): List + + @Query("DELETE FROM seerr_servers WHERE id = :serverId") + suspend fun deleteServer(serverId: Int) + + @Query("DELETE FROM seerr_users WHERE serverId = :serverId AND jellyfinUserRowId = :jellyfinUserRowId") + suspend fun deleteUser( + serverId: Int, + jellyfinUserRowId: Int, + ) + + suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) + + @Transaction + @Query("SELECT * FROM seerr_servers") + suspend fun getServers(): List + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE id = :serverId") + suspend fun getServer(serverId: Int): SeerrServerUsers? + + @Transaction + @Query("SELECT * FROM seerr_servers WHERE url = :url") + suspend fun getServer(url: String): SeerrServerUsers? +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index a396b873..1e5cade5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -242,7 +242,6 @@ class ServerRepository } suspend fun switchServerOrUser() { - apiClient.update(baseUrl = null, accessToken = null) userPreferencesDataStore.updateData { it .toBuilder() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index e5cd53bc..d1893dbb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,27 +1,40 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import com.github.damontecres.wholphin.ui.DateFormatter import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seriesProductionYears +import com.github.damontecres.wholphin.ui.timeRemaining import kotlinx.serialization.Serializable import kotlinx.serialization.Transient import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks +import java.util.Locale import kotlin.time.Duration @Serializable +@Stable data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, ) : CardGridItem { - override val id get() = data.id + val id get() = data.id + + override val gridId get() = id.toString() override val playable: Boolean get() = type.playable @@ -69,6 +82,61 @@ data class BaseItem( val favorite get() = data.userData?.isFavorite ?: false + @Transient + val timeRemainingOrRuntime: Duration? = data.timeRemaining ?: data.runTimeTicks?.ticks + + @Transient + val ui = + BaseItemUi( + quickDetails = + buildAnnotatedString { + val details = + buildList { + if (type == BaseItemKind.EPISODE) { + data.seasonEpisode?.let(::add) + data.premiereDate?.let { add(DateFormatter.format(it)) } + } else if (type == BaseItemKind.SERIES) { + data.seriesProductionYears?.let(::add) + } else { + data.productionYear?.let { add(it.toString()) } + } + data.runTimeTicks + ?.ticks + ?.roundMinutes + ?.let { add(it.toString()) } + data.timeRemaining + ?.roundMinutes + ?.let { add("$it left") } + } + details.forEachIndexed { index, string -> + append(string) + if (index != details.lastIndex) { + dot() + } + } + // TODO time remaining + + data.officialRating?.let { + dot() + append(it) + } + data.communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + data.criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + }, + ) + private fun dateAsIndex(): Int? = data.premiereDate ?.let { @@ -86,23 +154,21 @@ data class BaseItem( Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(seasonId, data.parentIndexNumber, id, indexNumber), ) - } ?: Destination.MediaItem(id, type, this) + } ?: Destination.MediaItem(this) } BaseItemKind.SEASON -> { Destination.SeriesOverview( data.seriesId!!, BaseItemKind.SERIES, - this, SeasonEpisodeIds(id, indexNumber, null, null), ) } else -> { - Destination.MediaItem(id, type, this) + Destination.MediaItem(this) } } return result @@ -122,3 +188,8 @@ data class BaseItem( } val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { h -> w.toFloat() / h.toFloat() } } + +@Immutable +data class BaseItemUi( + val quickDetails: AnnotatedString, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt new file mode 100644 index 00000000..a274caa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -0,0 +1,226 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.api.seerr.model.CreditCast +import com.github.damontecres.wholphin.api.seerr.model.CreditCrew +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.services.SeerrSearchResult +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toLocalDate +import com.github.damontecres.wholphin.util.LocalDateSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import java.time.LocalDate +import java.util.UUID + +@Serializable +enum class SeerrItemType( + val baseItemKind: BaseItemKind?, +) { + @SerialName("movie") + MOVIE(BaseItemKind.MOVIE), + + @SerialName("tv") + TV(BaseItemKind.SERIES), + + @SerialName("person") + PERSON(BaseItemKind.PERSON), + + @SerialName("unknown") + UNKNOWN(null), + ; + + companion object { + fun fromString( + str: String?, + fallback: SeerrItemType = UNKNOWN, + ) = when (str) { + "movie" -> MOVIE + "tv" -> TV + "person" -> PERSON + else -> fallback + } + } +} + +@Serializable +enum class SeerrAvailability( + val status: Int, +) { + UNKNOWN(1), + PENDING(2), + PROCESSING(3), + PARTIALLY_AVAILABLE(4), + AVAILABLE(5), + DELETED(6), + ; + + companion object { + fun from(status: Int?) = entries.firstOrNull { it.status == status } + } +} + +/** + * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. + */ +@Serializable +data class DiscoverItem( + val id: Int, + val type: SeerrItemType, + val title: String?, + val subtitle: String?, + val overview: String?, + val availability: SeerrAvailability, + @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, + val posterPath: String?, + val backdropPath: String?, + val jellyfinItemId: UUID?, +) : CardGridItem { + override val gridId: String get() = id.toString() + override val playable: Boolean = false + override val sortName: String get() = title ?: "" + + val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" } + val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } + + val destination: Destination + get() { + val jfType = + when (type) { + SeerrItemType.MOVIE -> BaseItemKind.MOVIE + SeerrItemType.TV -> BaseItemKind.SERIES + SeerrItemType.PERSON -> BaseItemKind.PERSON + SeerrItemType.UNKNOWN -> null + } + return if (jellyfinItemId != null && jfType != null) { + Destination.MediaItem( + itemId = jellyfinItemId, + type = jfType, + ) + } else { + Destination.DiscoveredItem(this) + } + } + + constructor(movie: MovieResult) : this( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(movie: MovieDetails) : this( + id = movie.id ?: -1, + type = SeerrItemType.MOVIE, + title = movie.title, + subtitle = null, + overview = movie.overview, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(movie.releaseDate), + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvResult) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvDetails) : this( + id = tv.id!!, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(tv.firstAirDate), + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(search: SeerrSearchResult) : this( + id = search.id, + type = SeerrItemType.fromString(search.mediaType), + title = search.title ?: search.name, + subtitle = null, + overview = search.overview, + availability = + SeerrAvailability.from(search.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate), + posterPath = search.posterPath, + backdropPath = search.backdropPath, + jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCast) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.character, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(credit: CreditCrew) : this( + id = credit.id!!, + type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON), + title = credit.name ?: credit.title, + subtitle = credit.job, + overview = credit.overview, + availability = + SeerrAvailability.from(credit.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + releaseDate = toLocalDate(credit.firstAirDate), + posterPath = credit.posterPath ?: credit.profilePath, + backdropPath = credit.backdropPath, + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) +} + +data class DiscoverRating( + val criticRating: Int?, + val audienceRating: Float?, +) { + constructor(rating: MovieMovieIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = rating.audienceScore?.div(10f), + ) + constructor(rating: TvTvIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = null, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt index 83f2d5df..94a30475 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ItemPlayback.kt @@ -54,4 +54,9 @@ object TrackIndex { * The user has explicitly disabled the tracks (eg turned off subtitles) */ const val DISABLED = -2 + + /** + * The user wants only forced subtitles (for foreign language dialogue, signs, etc.) + */ + const val ONLY_FORCED = -3 } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index 3430640e..c12d37b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.compose.runtime.Stable import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -7,6 +8,7 @@ import org.jellyfin.sdk.model.api.BaseItemPerson import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.PersonKind +@Stable data class Person( val id: UUID, val name: String?, diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt new file mode 100644 index 00000000..1e9f963d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrPermission.kt @@ -0,0 +1,49 @@ +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.services.SeerrUserConfig + +enum class SeerrPermission( + private val flag: Int, +) { + // Source: https://github.com/seerr-team/seerr/blob/develop/server/lib/permissions.ts + NONE(0), + ADMIN(2), + MANAGE_SETTINGS(4), + MANAGE_USERS(8), + MANAGE_REQUESTS(16), + REQUEST(32), + VOTE(64), + AUTO_APPROVE(128), + AUTO_APPROVE_MOVIE(256), + AUTO_APPROVE_TV(512), + REQUEST_4K(1024), + REQUEST_4K_MOVIE(2048), + REQUEST_4K_TV(4096), + REQUEST_ADVANCED(8192), + REQUEST_VIEW(16384), + AUTO_APPROVE_4K(32768), + AUTO_APPROVE_4K_MOVIE(65536), + AUTO_APPROVE_4K_TV(131072), + REQUEST_MOVIE(262144), + REQUEST_TV(524288), + MANAGE_ISSUES(1048576), + VIEW_ISSUES(2097152), + CREATE_ISSUES(4194304), + AUTO_REQUEST(8388608), + AUTO_REQUEST_MOVIE(16777216), + AUTO_REQUEST_TV(33554432), + RECENT_VIEW(67108864), + WATCHLIST_VIEW(134217728), + MANAGE_BLACKLIST(268435456), + VIEW_BLACKLIST(1073741824), + ; + + internal fun hasPermission(permissions: Int) = flag.and(permissions) == flag +} + +/** + * Check whether the user has the given permissions (or is an admin) + */ +fun SeerrUserConfig?.hasPermission(permission: SeerrPermission): Boolean { + return permission.hasPermission(this?.permissions ?: return false) || SeerrPermission.ADMIN.hasPermission(permissions) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt new file mode 100644 index 00000000..e29305d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SeerrServer.kt @@ -0,0 +1,72 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import androidx.room.Relation +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.serializer.UUIDSerializer + +@Entity( + tableName = "seerr_servers", + indices = [Index("url", unique = true)], +) +@Serializable +data class SeerrServer( + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + val url: String, + val name: String? = null, + val version: String? = null, +) + +@Entity( + tableName = "seerr_users", + foreignKeys = [ + ForeignKey( + entity = SeerrServer::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("serverId"), + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = JellyfinUser::class, + parentColumns = arrayOf("rowId"), + childColumns = arrayOf("jellyfinUserRowId"), + onDelete = ForeignKey.CASCADE, + ), + ], + primaryKeys = ["jellyfinUserRowId", "serverId"], +) +data class SeerrUser( + val jellyfinUserRowId: Int, + val serverId: Int, + val authMethod: SeerrAuthMethod, + val username: String?, + val password: String?, + val credential: String?, +) { + override fun toString(): String = + "SeerrUser(jellyfinUserRowId=$jellyfinUserRowId, serverId=$serverId, authMethod=$authMethod, username=$username, password?=${password.isNotNullOrBlank()}, credential?=${credential.isNotNullOrBlank()})" +} + +enum class SeerrAuthMethod { + LOCAL, + JELLYFIN, + API_KEY, +} + +data class SeerrServerUsers( + @Embedded val server: SeerrServer, + @Relation( + parentColumn = "id", + entityColumn = "serverId", + ) + val users: List, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt index bf82bbf3..943789db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Trailer.kt @@ -14,4 +14,5 @@ data class LocalTrailer( data class RemoteTrailer( override val name: String, val url: String, + val subtitle: String?, ) : Trailer diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 6bca9d38..f7f7ba25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -414,6 +414,29 @@ sealed interface AppPreference { summaryOff = R.string.disabled, ) + val DirectPlayDoviProfile7 = + AppSwitchPreference( + title = R.string.force_dovi_profile_7, + defaultValue = false, + getter = { it.playbackPreferences.overrides.directPlayDolbyVisionEL }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { directPlayDolbyVisionEL = value } + }, + summary = R.string.force_dovi_profile_7_summary, + ) + + val DecodeAv1 = + AppSwitchPreference( + title = R.string.software_decoding_av1, + defaultValue = true, + getter = { it.playbackPreferences.overrides.decodeAv1 }, + setter = { prefs, value -> + prefs.updatePlaybackOverrides { decodeAv1 = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val RememberSelectedTab = AppSwitchPreference( title = R.string.remember_selected_tab, @@ -696,7 +719,25 @@ sealed interface AppPreference { defaultValue = false, getter = { it.playbackPreferences.refreshRateSwitching }, setter = { prefs, value -> - prefs.updatePlaybackPreferences { refreshRateSwitching = value } + prefs.updatePlaybackPreferences { + if (!value) resolutionSwitching = false + refreshRateSwitching = value + } + }, + summaryOn = R.string.automatic, + summaryOff = R.string.disabled, + ) + + val ResolutionSwitching = + AppSwitchPreference( + title = R.string.resolution_switching, + defaultValue = false, + getter = { it.playbackPreferences.resolutionSwitching }, + setter = { prefs, value -> + prefs.updatePlaybackPreferences { + if (value) refreshRateSwitching = true + resolutionSwitching = value + } }, summaryOn = R.string.automatic, summaryOff = R.string.disabled, @@ -842,6 +883,13 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val SeerrIntegration = + AppClickablePreference( + title = R.string.seerr_integration, + getter = { }, + setter = { prefs, _ -> prefs }, + ) } } @@ -903,6 +951,7 @@ val basicPreferences = title = R.string.more, preferences = listOf( + AppPreference.SeerrIntegration, AppPreference.AdvancedSettings, ), ), @@ -934,6 +983,7 @@ val advancedPreferences = AppPreference.GlobalContentScale, AppPreference.MaxBitrate, AppPreference.RefreshRateSwitching, + AppPreference.ResolutionSwitching, AppPreference.PlaybackDebugInfo, ), ), @@ -965,6 +1015,8 @@ val advancedPreferences = AppPreference.Ac3Supported, AppPreference.DirectPlayAss, AppPreference.DirectPlayPgs, + AppPreference.DirectPlayDoviProfile7, + AppPreference.DecodeAv1, ), ), ConditionalPreferences( diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index c39b9012..f84744c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -52,6 +52,7 @@ class AppPreferencesSerializer playerBackend = AppPreference.PlayerBackendPref.defaultValue refreshRateSwitching = AppPreference.RefreshRateSwitching.defaultValue + resolutionSwitching = AppPreference.ResolutionSwitching.defaultValue overrides = PlaybackOverrides diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index cdbb7a2a..a64fc9d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -15,6 +15,7 @@ import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.request.bitmapConfig import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.BackdropStyle import dagger.hilt.android.qualifiers.ApplicationContext @@ -27,7 +28,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -47,27 +47,38 @@ class BackdropService suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) - if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { - _backdropFlow.update { - it.copy( - itemId = item.id, - imageUrl = null, - ) - } - extractColors(item) - } + val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + submit(item.id.toString(), imageUrl) } + suspend fun submit(item: DiscoverItem) = submit("discover_${item.id}", item.backDropUrl) + + suspend fun submit( + itemId: String, + imageUrl: String?, + ) = withContext(Dispatchers.IO) { + if (backdropFlow.firstOrNull()?.imageUrl != imageUrl) { + _backdropFlow.update { + it.copy( + itemId = itemId, + imageUrl = null, + ) + } + extractColors(itemId, imageUrl) + } + } + suspend fun clearBackdrop() { _backdropFlow.update { BackdropResult.NONE } } - private suspend fun extractColors(item: BaseItem) { + private suspend fun extractColors( + itemId: String, + imageUrl: String?, + ) { delay(500) - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) val backdropStyle = preferences.data .firstOrNull() @@ -83,9 +94,9 @@ class BackdropService ExtractedColors.DEFAULT } _backdropFlow.update { - if (it.itemId == item.id) { + if (it.itemId == itemId) { BackdropResult( - itemId = item.id, + itemId = itemId, imageUrl = imageUrl, primaryColor = primaryColor, secondaryColor = secondaryColor, @@ -208,7 +219,7 @@ class BackdropService } data class BackdropResult( - val itemId: UUID?, + val itemId: String?, val imageUrl: String?, val primaryColor: Color = Color.Unspecified, val secondaryColor: Color = Color.Unspecified, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt index 9ce70b21..976fb2c8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/DeviceProfileService.kt @@ -42,6 +42,8 @@ class DeviceProfileService downMixAudio = prefs.overrides.downmixStereo, assDirectPlay = prefs.overrides.directPlayAss, pgsDirectPlay = prefs.overrides.directPlayPgs, + dolbyVisionELDirectPlay = prefs.overrides.directPlayDolbyVisionEL, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = serverVersion != null && serverVersion >= ServerVersion(10, 11, 0), ) @@ -55,6 +57,8 @@ class DeviceProfileService downMixAudio = newConfig.downMixAudio, assDirectPlay = newConfig.assDirectPlay, pgsDirectPlay = newConfig.pgsDirectPlay, + dolbyVisionELDirectPlay = newConfig.dolbyVisionELDirectPlay, + decodeAv1 = prefs.overrides.decodeAv1, jellyfinTenEleven = newConfig.jellyfinTenEleven, ) } @@ -72,5 +76,7 @@ data class DeviceProfileConfiguration( val downMixAudio: Boolean, val assDirectPlay: Boolean, val pgsDirectPlay: Boolean, + val dolbyVisionELDirectPlay: Boolean, + val decodeAv1: Boolean, val jellyfinTenEleven: Boolean, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 164035e7..39967e24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,10 +28,11 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, + imageTags: Map, imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String = + ): String? = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, @@ -66,6 +67,13 @@ class ImageUrlService fillWidth = fillWidth, fillHeight = fillHeight, ) + } else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) } else { getItemImageUrl( itemId = itemId, @@ -98,6 +106,7 @@ class ImageUrlService itemType = item.type, seriesId = item.data.seriesId, useSeriesForPrimary = item.useSeriesForPrimary, + imageTags = item.data.imageTags.orEmpty(), imageType = imageType, fillWidth = fillWidth, fillHeight = fillHeight, @@ -124,8 +133,9 @@ class ImageUrlService backgroundColor: String? = null, foregroundLayer: String? = null, imageIndex: Int? = null, - ): String = - api.imageApi.getItemImageUrl( + ): String? { + if (api.baseUrl.isNullOrBlank()) return null + return api.imageApi.getItemImageUrl( itemId = itemId, imageType = imageType, maxWidth = maxWidth, @@ -144,6 +154,7 @@ class ImageUrlService foregroundLayer = foregroundLayer, imageIndex = imageIndex, ) + } fun getUserImageUrl(userId: UUID) = api.imageApi.getUserImageUrl(userId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt new file mode 100644 index 00000000..44ed1a58 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -0,0 +1,190 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.main.LatestData +import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.supportItemKinds +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import timber.log.Timber +import java.time.LocalDateTime +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class LatestNextUpService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val datePlayedService: DatePlayedService, + ) { + suspend fun getResume( + userId: UUID, + limit: Int, + includeEpisodes: Boolean, + ): List { + val request = + GetResumeItemsRequest( + userId = userId, + fields = SlimItemFields, + limit = limit, + includeItemTypes = + if (includeEpisodes) { + supportItemKinds + } else { + supportItemKinds + .toMutableSet() + .apply { + remove(BaseItemKind.EPISODE) + } + }, + ) + val items = + api.itemsApi + .getResumeItems(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return items + } + + suspend fun getNextUp( + userId: UUID, + limit: Int, + enableRewatching: Boolean, + enableResumable: Boolean, + ): List { + val request = + GetNextUpRequest( + userId = userId, + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + limit = limit, + enableResumable = enableResumable, + enableUserData = true, + enableRewatching = enableRewatching, + ) + val nextUp = + api.tvShowsApi + .getNextUp(request) + .content + .items + .map { BaseItem.from(it, api, true) } + return nextUp + } + + suspend fun getLatest( + user: UserDto, + limit: Int, + includedIds: List, + ): List { + val excluded = user.configuration?.latestItemsExcludes.orEmpty() + val views by api.userViewsApi.getUserViews() + val latestData = + views.items + .filter { + it.id in includedIds && it.id !in excluded && + it.collectionType in supportedLatestCollectionTypes + }.map { view -> + val title = + view.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = view.id, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + LatestData(title, request) + } + + return latestData + } + + suspend fun loadLatest(latestData: List): List { + val rows = + latestData.mapNotNull { (title, request) -> + try { + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.from(it, api, true) } + if (latest.isNotEmpty()) { + HomeRowLoadingState.Success( + title = title, + items = latest, + ) + } else { + null + } + } catch (ex: Exception) { + Timber.e(ex, "Exception fetching %s", title) + HomeRowLoadingState.Error( + title = title, + exception = ex, + ) + } + } + return rows + } + + suspend fun buildCombined( + resume: List, + nextUp: List, + ): List = + withContext(Dispatchers.IO) { + val start = System.currentTimeMillis() + val semaphore = Semaphore(3) + val deferred = + nextUp + .filter { it.data.seriesId != null } + .map { item -> + async(Dispatchers.IO) { + try { + semaphore.withPermit { + datePlayedService.getLastPlayed(item) + } + } catch (ex: Exception) { + Timber.e(ex, "Error fetching %s", item.id) + null + } + } + } + + val nextUpLastPlayed = deferred.awaitAll() + val timestamps = mutableMapOf() + nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) + resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } + val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } + val duration = (System.currentTimeMillis() - start).milliseconds + Timber.v("buildCombined took %s", duration) + return@withContext result + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt index 136cb746..63a85cef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavigationManager.kt @@ -68,9 +68,20 @@ class NavigationManager log() } + /** + * Resets the backstack to the specified destination + */ + fun replace(destination: Destination) { + while (backStack.size > 1) { + backStack.removeLastOrNull() + } + backStack[0] = destination + log() + } + private fun log() { val dest = backStack.lastOrNull().toString() - Timber.Forest.i("Current Destination: %s", dest) + Timber.i("Current Destination: %s", dest) ACRA.errorReporter.putCustomData("destination", dest) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 86906280..3c32a497 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -20,6 +20,10 @@ class PlaybackLifecycleObserver private var wasPlaying: Boolean? = null override fun onStart(owner: LifecycleOwner) { + val lastDest = navigationManager.backStack.lastOrNull() + if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + navigationManager.goBack() + } wasPlaying = null } @@ -40,9 +44,6 @@ class PlaybackLifecycleObserver } override fun onStop(owner: LifecycleOwner) { - if (navigationManager.backStack.lastOrNull() is Destination.Playback) { - navigationManager.goBack() - } themeSongPlayer.stop() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt index 5850f05a..f765ba86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlayerFactory.kt @@ -3,15 +3,22 @@ package com.github.damontecres.wholphin.services import android.content.Context +import android.os.Build +import android.os.Handler import androidx.annotation.OptIn import androidx.datastore.core.DataStore +import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.DefaultDataSource import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +import androidx.media3.exoplayer.video.VideoRendererEventListener import androidx.media3.extractor.DefaultExtractorsFactory import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences @@ -27,6 +34,7 @@ import io.github.peerless2012.ass.media.type.AssRenderType import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import timber.log.Timber +import java.lang.reflect.Constructor import javax.inject.Inject import javax.inject.Singleton @@ -74,6 +82,7 @@ class PlayerFactory val directPlayAss = prefs?.overrides?.directPlayAss ?: AppPreference.DirectPlayAss.defaultValue + val decodeAv1 = prefs?.overrides?.decodeAv1 == true Timber.v("extensions=$extensions, directPlayAss=$directPlayAss") val rendererMode = when (extensions) { @@ -85,7 +94,7 @@ class PlayerFactory val dataSourceFactory = DefaultDataSource.Factory(context) val extractorsFactory = DefaultExtractorsFactory() var renderersFactory: RenderersFactory = - DefaultRenderersFactory(context) + WholphinRenderersFactory(context, decodeAv1) .setEnableDecoderFallback(true) .setExtensionRendererMode(rendererMode) val mediaSourceFactory = @@ -135,3 +144,73 @@ data class PlayerCreation( val player: Player, val assHandler: AssHandler? = null, ) + +// Code is adapted from https://github.com/androidx/media/blob/release/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/DefaultRenderersFactory.java#L436 +class WholphinRenderersFactory( + context: Context, + private val av1Enabled: Boolean, +) : DefaultRenderersFactory(context) { + override fun buildVideoRenderers( + context: Context, + extensionRendererMode: Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList, + ) { + var videoRendererBuilder = + MediaCodecVideoRenderer + .Builder(context) + .setCodecAdapterFactory(codecAdapterFactory) + .setMediaCodecSelector(mediaCodecSelector) + .setAllowedJoiningTimeMs(allowedVideoJoiningTimeMs) + .setEnableDecoderFallback(enableDecoderFallback) + .setEventHandler(eventHandler) + .setEventListener(eventListener) + .setMaxDroppedFramesToNotify(MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) + .experimentalSetParseAv1SampleDependencies(false) + .experimentalSetLateThresholdToDropDecoderInputUs(C.TIME_UNSET) + if (Build.VERSION.SDK_INT >= 34) { + videoRendererBuilder = + videoRendererBuilder.experimentalSetEnableMediaCodecBufferDecodeOnlyFlag( + false, + ) + } + out.add(videoRendererBuilder.build()) + + if (extensionRendererMode == EXTENSION_RENDERER_MODE_OFF) { + return + } + var extensionRendererIndex = out.size + if (extensionRendererMode == EXTENSION_RENDERER_MODE_PREFER) { + extensionRendererIndex-- + } + + if (av1Enabled) { + try { + val clazz = Class.forName("androidx.media3.decoder.av1.Libdav1dVideoRenderer") + val constructor: Constructor<*> = + clazz.getConstructor( + Long::class.javaPrimitiveType, + Handler::class.java, + VideoRendererEventListener::class.java, + Int::class.javaPrimitiveType, + ) + val renderer = + constructor.newInstance( + allowedVideoJoiningTimeMs, + eventHandler, + eventListener, + MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY, + ) as Renderer + out.add(extensionRendererIndex++, renderer) + Timber.i("Loaded Libdav1dVideoRenderer.") + } catch (e: Exception) { + // The extension is present, but instantiation failed. + throw java.lang.IllegalStateException("Error instantiating AV1 extension", e) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index e34d2fa1..be6e461e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -32,41 +32,61 @@ class RefreshRateService private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) private val originalMode = display.mode - val displayModes get() = display.supportedModes + val supportedDisplayModes get() = display.supportedModes.orEmpty() - private val _refreshRateMode = EqualityMutableLiveData(originalMode) - val refreshRateMode: LiveData = _refreshRateMode + private val displayModes: List by lazy { + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + } + + private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) + val refreshRateMode: LiveData = _refreshRateMode /** * Find the best display mode for the given stream and signal to change to it */ - suspend fun changeRefreshRate(stream: MediaStream) { + suspend fun changeRefreshRate( + stream: MediaStream, + switchRefreshRate: Boolean, + switchResolution: Boolean, + ) { + if (!switchRefreshRate && !switchResolution) { + Timber.v("Not switching either refresh rate nor resolution") + return + } + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width val height = stream.height - val frameRate = stream.realFrameRate?.times(100)?.roundToInt() + val frameRate = + if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate if (width == null || height == null || frameRate == null) { Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) return } Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) val targetMode = - display.supportedModes - .filterNot { it.physicalHeight < height || it.physicalWidth < width } - .filter { - (it.refreshRate * 100).roundToInt().let { modeRate -> - frameRate % modeRate == 0 || // Exact multiple - modeRate == (frameRate * 2.5).roundToInt() // eg 24 & 60fps - } - }.maxByOrNull { it.physicalWidth * it.physicalHeight } - Timber.i("Found display mode: %s, current=${display.mode}", targetMode) - if (targetMode != null && targetMode != display.mode) { + findDisplayMode( + displayModes = displayModes, + streamWidth = width, + streamHeight = height, + targetFrameRate = frameRate, + refreshRateSwitch = switchRefreshRate, + resolutionSwitch = switchResolution, + ) + Timber.i("Found display mode: %s, current=%s", targetMode, currentDisplayMode) + if (targetMode != null && targetMode.modeId != currentDisplayMode.modeId) { val listener = Listener(display.displayId) displayManager.registerDisplayListener( listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode) + _refreshRateMode.setValueOnMain(targetMode.modeId) try { if (!listener.latch.await(5, TimeUnit.SECONDS)) { Timber.w("Timed out waiting for display change") @@ -75,13 +95,13 @@ class RefreshRateService } catch (ex: InterruptedException) { Timber.w(ex, "Exception waiting for refresh rate switch") } - val targetRate = (targetMode.refreshRate * 100).roundToInt() + val targetRate = (targetMode.refreshRate * 1000).roundToInt() val isSeamless = - targetRate == (display.mode.refreshRate * 100).roundToInt() || + targetRate == (currentDisplayMode.refreshRate * 1000).roundToInt() || if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - display.mode.alternativeRefreshRates - .map { (it * 100).roundToInt() } - .any { targetRate % it == 0 } + currentDisplayMode.alternativeRefreshRates + .map { (it * 1000).roundToInt() } + .any { it % targetRate == 0 } } else { false } @@ -98,7 +118,7 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode + _refreshRateMode.value = originalMode.modeId } private class Listener( @@ -119,4 +139,69 @@ class RefreshRateService override fun onDisplayRemoved(displayId: Int) { } } + + companion object { + /** + * Find the best display mode for the given stream & preferences + * + * @param displayModes candidates that are sorted by resolution and frame rate descending + */ + fun findDisplayMode( + displayModes: List, + streamWidth: Int, + streamHeight: Int, + targetFrameRate: Float, + refreshRateSwitch: Boolean, + resolutionSwitch: Boolean, + ): DisplayMode? { + val streamRate = targetFrameRate.times(1000).roundToInt() +// Timber.v("display modes: %s", displayModes.joinToString("\n")) + val candidates = + if (refreshRateSwitch) { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + .filter { + it.refreshRateRounded % streamRate == 0 || // Exact multiple + it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } + } else { + displayModes + .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } + } +// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) + return if (!resolutionSwitch) { + candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + } else { + candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates.firstOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight >= streamHeight && + it.refreshRateRounded == streamRate + } + ?: candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + } + } + } } + +data class DisplayMode( + val modeId: Int, + val physicalWidth: Int, + val physicalHeight: Int, + val refreshRate: Float, +) { + val refreshRateRounded: Int = (refreshRate * 1000).roundToInt() + + constructor(mode: Display.Mode) : this( + mode.modeId, + mode.physicalWidth, + mode.physicalHeight, + mode.refreshRate, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt new file mode 100644 index 00000000..c68cd3d2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -0,0 +1,29 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import okhttp3.OkHttpClient + +/** + * Wrapper for [SeerrApiClient]. In most cases, you should use [SeerrService] instead. + */ +class SeerrApi( + private val okHttpClient: OkHttpClient, +) { + var api: SeerrApiClient = + SeerrApiClient( + baseUrl = "", + apiKey = null, + okHttpClient = okHttpClient, + ) + private set + + val active: Boolean get() = api.baseUrl.isNotNullOrBlank() + + fun update( + baseUrl: String, + apiKey: String?, + ) { + api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt new file mode 100644 index 00000000..e267a12f --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -0,0 +1,262 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.asFlow +import androidx.lifecycle.lifecycleScope +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.AuthJellyfinPostRequest +import com.github.damontecres.wholphin.api.seerr.model.AuthLocalPostRequest +import com.github.damontecres.wholphin.api.seerr.model.PublicSettings +import com.github.damontecres.wholphin.api.seerr.model.User +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.SeerrServer +import com.github.damontecres.wholphin.data.model.SeerrUser +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import okhttp3.OkHttpClient +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Manages saves/loading Seerr servers from the local DB. Also will update the current [SeerrApi] as needed. + */ +@Singleton +class SeerrServerRepository + @Inject + constructor( + private val seerrApi: SeerrApi, + private val seerrServerDao: SeerrServerDao, + private val serverRepository: ServerRepository, + @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, + ) { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current + val currentServer: Flow = current.map { it?.server } + val currentUser: Flow = current.map { it?.user } + + /** + * Whether Seerr integration is currently active of not + */ + val active: Flow = current.map { it != null && seerrApi.active } + + fun clear() { + _current.update { null } + seerrApi.update("", null) + } + + suspend fun set( + server: SeerrServer, + user: SeerrUser, + userConfig: SeerrUserConfig, + ) { + val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() + _current.update { + CurrentSeerr(server, user, userConfig, publicSettings) + } + } + + suspend fun addAndChangeServer( + url: String, + apiKey: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO test api key + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = SeerrAuthMethod.API_KEY, + username = null, + password = null, + credential = apiKey, + ) + seerrServerDao.addUser(user) + + seerrApi.update(server.url, apiKey) + val userConfig = seerrApi.api.usersApi.authMeGet() + set(server, user, userConfig) + } + } + } + + suspend fun addAndChangeServer( + url: String, + authMethod: SeerrAuthMethod, + username: String, + password: String, + ) { + var server = seerrServerDao.getServer(url) + if (server == null) { + seerrServerDao.addServer(SeerrServer(url = url)) + server = seerrServerDao.getServer(url) + } + server?.server?.let { server -> + serverRepository.currentUser.value?.let { jellyfinUser -> + // TODO Need to update server early so that cookies are saved + seerrApi.update(server.url, null) + val userConfig = login(seerrApi.api, authMethod, username, password) + + val user = + SeerrUser( + jellyfinUserRowId = jellyfinUser.rowId, + serverId = server.id, + authMethod = authMethod, + username = username, + password = password, + credential = null, + ) + seerrServerDao.addUser(user) + set(server, user, userConfig) + } + } + } + + suspend fun testConnection( + authMethod: SeerrAuthMethod, + url: String, + username: String?, + passwordOrApiKey: String, + ): LoadingState { + val api = SeerrApiClient(url, passwordOrApiKey, okHttpClient) + login(api, authMethod, username, passwordOrApiKey) + return LoadingState.Success + } + + suspend fun removeServer() { + val current = _current.value ?: return + seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + clear() + } + } + +/** + * A [SeerrUser] config + */ +typealias SeerrUserConfig = User + +data class CurrentSeerr( + val server: SeerrServer, + val user: SeerrUser, + val config: SeerrUserConfig, + val serverConfig: PublicSettings, +) { + val request4kMovieEnabled: Boolean + get() = + (serverConfig.movie4kEnabled ?: false) && + config.hasPermission(SeerrPermission.REQUEST_4K_MOVIE) + + val request4kTvEnabled: Boolean + get() = + (serverConfig.series4kEnabled ?: false) && + config.hasPermission(SeerrPermission.REQUEST_4K_TV) +} + +private suspend fun login( + client: SeerrApiClient, + authMethod: SeerrAuthMethod, + username: String?, + password: String?, +): User = + when (authMethod) { + SeerrAuthMethod.LOCAL -> { + client.authApi.authLocalPost( + AuthLocalPostRequest( + email = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.JELLYFIN -> { + client.authApi.authJellyfinPost( + AuthJellyfinPostRequest( + username = username ?: "", + password = password ?: "", + ), + ) + } + + SeerrAuthMethod.API_KEY -> { + client.usersApi.authMeGet() + } + } + +/** + * Listens for JF user switching in the app to also switch the Seerr user/server + */ +@ActivityScoped +class UserSwitchListener + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val seerrServerRepository: SeerrServerRepository, + private val seerrServerDao: SeerrServerDao, + private val seerrApi: SeerrApi, + ) { + init { + context as AppCompatActivity + context.lifecycleScope.launchIO { + serverRepository.currentUser.asFlow().collect { user -> + Timber.d("New user") + seerrServerRepository.clear() + if (user != null) { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .firstOrNull() + ?.let { seerrUser -> + val server = seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + try { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } else { + try { + seerrApi.api.usersApi.authMeGet() + } catch (ex: Exception) { + Timber.w(ex, "Error logging into %s", server.url) + seerrServerRepository.clear() + return@let + } + } + seerrServerRepository.set(server, seerrUser, userConfig) + } + } + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt new file mode 100644 index 00000000..e38ba41b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -0,0 +1,128 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.model.api.BaseItemKind +import javax.inject.Inject +import javax.inject.Singleton + +typealias SeerrSearchResult = SearchGet200ResponseResultsInner + +/** + * Main access for the current Seerr server (if any) + * + * Exposes a [SeerrApiClient] for queries + */ +@Singleton +class SeerrService + @Inject + constructor( + private val seerApi: SeerrApi, + private val seerrServerRepository: SeerrServerRepository, + ) { + val api: SeerrApiClient get() = seerApi.api + + val active get() = seerrServerRepository.active + + suspend fun search( + query: String, + page: Int = 1, + ): List = + api.searchApi + .searchGet(query = query, page = page) + .results + .orEmpty() + + suspend fun discoverTv(page: Int = 1): List = + api.searchApi + .discoverTvGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun discoverMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun trending(page: Int = 1): List = + api.searchApi + .discoverTrendingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingMovies(page: Int = 1): List = + api.searchApi + .discoverMoviesUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + suspend fun upcomingTv(page: Int = 1): List = + api.searchApi + .discoverTvUpcomingGet(page = page) + .results + ?.map(::DiscoverItem) + .orEmpty() + + /** + * Get [DiscoverItem]s similar to the JF items such as movies, series, or people + * + * If Seerr integration is not active, this short circuits to return null + * + * @return the discovered items or null if no Seerr server configured + */ + suspend fun similar(item: BaseItem): List? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + when (item.type) { + BaseItemKind.MOVIE -> { + api.moviesApi + .movieMovieIdSimilarGet(movieId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { + api.tvApi + .tvTvIdSimilarGet(tvId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.PERSON -> { + api.personApi + .personPersonIdCombinedCreditsGet(personId = it) + .let { credits -> + val cast = + credits.cast + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.take(25) + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + } + + else -> { + null + } + } + }.orEmpty() + } else { + null + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt index 703821fe..e38957cf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/StreamChoiceService.kt @@ -153,7 +153,7 @@ class StreamChoiceService return source.mediaStreams?.letNotEmpty { streams -> val candidates = streams.filter { it.type == MediaStreamType.SUBTITLE } chooseSubtitleStream( - audioStream, + audioStream?.language, candidates, itemPlayback, plc, @@ -162,8 +162,42 @@ class StreamChoiceService } } + /** + * Resolves ONLY_FORCED to an actual subtitle track index. + * Returns the original index if not ONLY_FORCED or DISABLED. + */ + suspend fun resolveSubtitleIndex( + source: MediaSourceInfo, + audioStreamIndex: Int?, + seriesId: UUID?, + subtitleIndex: Int, + prefs: UserPreferences, + ): Int? = + if (subtitleIndex != TrackIndex.ONLY_FORCED) { + subtitleIndex + } else { + val audioStream = + source.mediaStreams?.firstOrNull { + it.type == MediaStreamType.AUDIO && it.index == audioStreamIndex + } + val itemPlayback = + ItemPlayback( + userId = serverRepository.currentUser.value!!.rowId, + itemId = UUID.randomUUID(), // Not used for ONLY_FORCED resolution + subtitleIndex = TrackIndex.ONLY_FORCED, + ) + chooseSubtitleStream( + source = source, + audioStream = audioStream, + seriesId = seriesId, + itemPlayback = itemPlayback, + plc = null, + prefs = prefs, + )?.index + } + fun chooseSubtitleStream( - audioStream: MediaStream?, + audioStreamLang: String?, candidates: List, itemPlayback: ItemPlayback?, playbackLanguageChoice: PlaybackLanguageChoice?, @@ -171,6 +205,14 @@ class StreamChoiceService ): MediaStream? { if (itemPlayback?.subtitleIndex == TrackIndex.DISABLED) { return null + } else if (itemPlayback?.subtitleIndex == TrackIndex.ONLY_FORCED) { + // Client-side manual override: User selected "Only Forced" in player menu + val seriesLang = + playbackLanguageChoice?.subtitleLanguage?.takeIf { it.isNotNullOrBlank() } + val subtitleLanguage = + (seriesLang ?: userConfig?.subtitleLanguagePreference) + ?.takeIf { it.isNotNullOrBlank() } + return findForcedTrack(candidates, subtitleLanguage, audioStreamLang) } else if (itemPlayback?.subtitleIndexEnabled == true) { return candidates.firstOrNull { it.index == itemPlayback.subtitleIndex } } else { @@ -198,48 +240,61 @@ class StreamChoiceService userConfig?.subtitleMode ?: SubtitlePlaybackMode.DEFAULT } } + val candidates = + candidates + .sortedWith( + compareByDescending { it.isExternal } + .thenByDescending { it.isDefault } + .thenByDescending { + !it.isForced && it.language.equals(subtitleLanguage, true) + }.thenByDescending { + it.isForced && it.language.equals(subtitleLanguage, true) + }.thenByDescending { it.isForced && it.language.isUnknown } + .thenByDescending { it.isForced }, + ) return when (subtitleMode) { SubtitlePlaybackMode.ALWAYS -> { - if (subtitleLanguage != null) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { + it.language.equals(subtitleLanguage, true) || + it.language.isUnknown + } } else { candidates.firstOrNull() } } SubtitlePlaybackMode.ONLY_FORCED -> { - if (subtitleLanguage != null) { + if (subtitleLanguage.isNotNullOrBlank()) { candidates.firstOrNull { it.language == subtitleLanguage && it.isForced } + ?: candidates.firstOrNull { it.language.isUnknown && it.isForced } } else { candidates.firstOrNull { it.isForced } } } SubtitlePlaybackMode.SMART -> { - val audioLanguage = userConfig?.audioLanguagePreference - val audioStreamLang = audioStream?.language - if (audioLanguage.isNotNullOrBlank() && audioStreamLang.isNotNullOrBlank() && audioLanguage != audioStreamLang) { - candidates.firstOrNull { it.language == subtitleLanguage } + if (subtitleLanguage.isNotNullOrBlank()) { + val audioLanguage = userConfig?.audioLanguagePreference + if (audioLanguage.isNullOrBlank() || audioLanguage != audioStreamLang) { + candidates.firstOrNull { it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.language.isUnknown } + } else { + candidates.firstOrNull { it.isForced && it.language == subtitleLanguage } + ?: candidates.firstOrNull { it.isForced && it.language.isUnknown } + } } else { - null + candidates.firstOrNull { it.isDefault } } } SubtitlePlaybackMode.DEFAULT -> { - subtitleLanguage?.let { lang -> - // Find best track that is in the preferred language - ( - candidates.firstOrNull { it.isDefault && it.isForced && it.language == lang } - ?: candidates.firstOrNull { it.isDefault && it.language == lang } - ?: candidates.firstOrNull { it.isForced && it.language == lang } - ) + if (subtitleLanguage.isNotNullOrBlank()) { + candidates.firstOrNull { it.language == subtitleLanguage && (it.isDefault || it.isForced) } + ?: candidates.firstOrNull { it.isDefault || it.isForced } + } else { + candidates.firstOrNull { it.isDefault || it.isForced } } - ?: ( - // If none in preferred language, just find the best track - candidates.firstOrNull { it.isDefault && it.isForced } - ?: candidates.firstOrNull { it.isDefault } - ?: candidates.firstOrNull { it.isForced } - ) } SubtitlePlaybackMode.NONE -> { @@ -248,4 +303,44 @@ class StreamChoiceService } } } + + /** Returns true if the track is forced (via metadata flag or title patterns). */ + private fun isForcedOrSigns(track: MediaStream): Boolean { + if (track.isForced) return true + val title = track.title ?: track.displayTitle ?: return false + return title.contains("forced", ignoreCase = true) || + title.contains("signs", ignoreCase = true) || + title.contains("songs", ignoreCase = true) + } + + /** Finds a forced/signs track: subtitle pref -> audio -> unknown -> null. */ + private fun findForcedTrack( + candidates: List, + subtitleLanguage: String?, + audioLanguage: String?, + ): MediaStream? { + // 1. User's preferred subtitle language + if (subtitleLanguage != null) { + candidates + .firstOrNull { it.language.equals(subtitleLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 2. Audio language (for sign-subtitles if no preference match) + if (audioLanguage != null) { + candidates + .firstOrNull { it.language.equals(audioLanguage, true) && isForcedOrSigns(it) } + ?.let { return it } + } + // 3. Unknown language forced track + return candidates.firstOrNull { it.language.isUnknown && isForcedOrSigns(it) } + } } + +private val String?.isUnknown: Boolean + get() = + this == null || + this.equals("unknown", true) || + this.equals("und", true) || + this.equals("undetermined", true) || + this.equals("mul", true) || + this.equals("zxx", true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt index a5f54815..e6bcb867 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ThemeSongPlayer.kt @@ -104,7 +104,9 @@ class ThemeSongPlayer } fun stop() { - Timber.v("Stopping theme song") - player.stop() + if (player.isPlaying) { + Timber.v("Stopping theme song") + player.stop() + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt index 1af38ddd..3f676cba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/TrailerService.kt @@ -22,31 +22,53 @@ class TrailerService @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { - suspend fun getTrailers(item: BaseItem): List { - val remoteTrailers = - item.data.remoteTrailers - ?.mapNotNull { t -> - t.url?.let { url -> - val name = - t.name - // TODO would be nice to clean up the trailer name + fun getRemoteTrailers(item: BaseItem): List = + item.data.remoteTrailers + ?.mapNotNull { t -> + t.url?.let { url -> + val name = + t.name + // TODO would be nice to clean up the trailer name // ?.replace(item.name ?: "", "") // ?.removePrefix(" - ") - ?: context.getString(R.string.trailer) - RemoteTrailer(name, url) - } - }.orEmpty() - .sortedBy { it.name } - val localTrailerCount = item.data.localTrailerCount ?: 0 + ?: context.getString(R.string.trailer) + val subtitle = + when (url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + RemoteTrailer(name, url, subtitle) + } + }.orEmpty() + .sortedWith( + compareBy( + { + // Try to show official trailers first & teasers last + when { + it.name.contains("Official Trailer", true) -> 0 + it.name.contains("Official Theatrical Trailer", true) -> 0 + it.name.contains("Teaser", true) -> 10 + it.name.contains("Trailer", true) -> 1 + else -> 5 + } + }, + { + it.name + }, + ), + ) + + suspend fun getLocalTrailers(item: BaseItem): List { + val localTrailerCount = item.data.localTrailerCount ?: return emptyList() val localTrailers = if (localTrailerCount > 0) { api.userLibraryApi.getLocalTrailers(item.id).content.map { - LocalTrailer(BaseItem.Companion.from(it, api)) + LocalTrailer(BaseItem.from(it, api)) } } else { listOf() } - return localTrailers + remoteTrailers + return localTrailers } companion object { @@ -66,7 +88,6 @@ class TrailerService navigateTo.invoke( Destination.Playback( itemId = trailer.baseItem.id, - item = trailer.baseItem, positionMs = 0L, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index c7ac435c..f4aa80dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -33,7 +33,6 @@ import kotlinx.serialization.json.jsonPrimitive import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response -import okhttp3.internal.headersContentLength import timber.log.Timber import java.io.File import java.io.InputStream @@ -211,7 +210,7 @@ class UpdateChecker if (it.isSuccessful && it.body != null) { Timber.v("Request successful for ${release.downloadUrl}") withContext(Dispatchers.Main) { - callback.contentLength(it.headersContentLength()) + callback.contentLength(it.body.contentLength()) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val contentValues = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index ad778590..e6ae832a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.services.SeerrApi import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -174,4 +175,10 @@ object AppModule { @Singleton @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = SeerrApi(okHttpClient) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index 30fb4422..ed3f761b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.JellyfinServerDao import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao import com.github.damontecres.wholphin.data.Migrations import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer @@ -61,6 +62,10 @@ object DatabaseModule { @Singleton fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt new file mode 100644 index 00000000..05ce1d2a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -0,0 +1,89 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.await +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import timber.log.Timber +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@ActivityScoped +class TvProviderSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + ) { + private val activity = (context as AppCompatActivity) + private val workManager = WorkManager.getInstance(context) + + private val supportsTvProvider = + // TODO <=25 has limited support + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && + context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + + init { + serverRepository.current.observe(activity) { user -> + workManager.cancelUniqueWork(TvProviderWorker.WORK_NAME) + if (supportsTvProvider) { + if (user != null) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + Timber.i("Scheduling TvProviderWorker for ${user.user}") + workManager + .enqueueUniquePeriodicWork( + uniqueWorkName = TvProviderWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 1.hours.toJavaDuration(), + ).setBackoffCriteria( + BackoffPolicy.LINEAR, + 15.minutes.toJavaDuration(), + ).setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ).await() + } + } + } + } + } + + fun launchOneTimeRefresh() { + if (supportsTvProvider) { + activity.lifecycleScope.launchIO(ExceptionHandler()) { + serverRepository.current.value?.let { user -> + Timber.i("Scheduling on-time TvProviderWorker for ${user.user}") + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setInputData( + workDataOf( + TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), + TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), + ), + ).build(), + ) + } + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt new file mode 100644 index 00000000..134e4903 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -0,0 +1,409 @@ +package com.github.damontecres.wholphin.services.tvprovider + +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.database.Cursor +import android.net.Uri +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.tvprovider.media.tv.Channel +import androidx.tvprovider.media.tv.PreviewProgram +import androidx.tvprovider.media.tv.TvContractCompat +import androidx.tvprovider.media.tv.TvContractCompat.Channels +import androidx.tvprovider.media.tv.TvContractCompat.WatchNextPrograms +import androidx.tvprovider.media.tv.WatchNextProgram +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.ui.SlimItemFields +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.flow.firstOrNull +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.extensions.ticks +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.time.ZoneId +import java.util.Date +import java.util.UUID +import kotlin.time.Duration.Companion.minutes + +@HiltWorker +class TvProviderWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore, + private val api: ApiClient, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = + inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = + inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + // Not active + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val potentialItemsToAdd = + getPotentialItems( + userId, + prefs.homePagePreferences.enableRewatchingNextUp, + prefs.homePagePreferences.combineContinueNext, + ) + val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } + + Timber.v("potentialItemsToAddIds=%s", potentialItemsToAddIds) + val currentItems = getCurrentTvChannelNextUp() + val currentItemIds = currentItems.map { it.internalProviderId } + + // TODO Remove after v0.3.10 release + // This cleans up duplicates added to the watch next due a bug in https://github.com/damontecres/Wholphin/pull/372 + currentItems.groupBy { it.internalProviderId }.forEach { (id, items) -> + if (items.size > 1) { + Timber.v("Duplicate ID %s", id) + items + .subList(1, items.size) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + } + } + // End temporary clean up + + val toRemove = + currentItems.filterNot { it.internalProviderId in potentialItemsToAddIds } + + val userRemoved = currentItems.filterNot { it.isBrowsable } + val userRemovedIds = userRemoved.map { it.internalProviderId } + Timber.v("toRemove (%s)=%s", toRemove.size, toRemove.map { it.internalProviderId }) + val toAdd = + potentialItemsToAdd.filter { it.id.toString() !in currentItemIds && it.id.toString() !in userRemovedIds } + Timber.v("toAdd (%s)=%s", toAdd.size, toAdd.map { it.id }) + + // Remove existing items if they are no longer in the next up from server + (toRemove + userRemoved) + .map { TvContractCompat.buildWatchNextProgramUri(it.id) } + .forEach { + context.contentResolver.delete(it, null, null) + } + + // Add new ones + val addedCount = + context.contentResolver.bulkInsert( + WatchNextPrograms.CONTENT_URI, + toAdd + .map { convert(it).toContentValues() } + .toTypedArray(), + ) + Timber.v("Added %s", addedCount) + + addOtherChannels() + + Timber.d("Completed successfully") + } catch (_: ApiClientException) { + return Result.retry() + } catch (_: Exception) { + return Result.failure() + } + return Result.success() + } + + private suspend fun getPotentialItems( + userId: UUID, + enableRewatching: Boolean, + combineContinueNext: Boolean, + ): List { + val resumeItems = latestNextUpService.getResume(userId, 10, true) + val seriesIds = resumeItems.mapNotNull { it.data.seriesId } + val nextUpItems = + latestNextUpService + .getNextUp(userId, 10, enableRewatching, false) + .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } + return if (combineContinueNext) { + latestNextUpService.buildCombined(resumeItems, nextUpItems) + } else { + resumeItems + nextUpItems + } + } + + private suspend fun getCurrentTvChannelNextUp(): List = + context.contentResolver + .query( + WatchNextPrograms.CONTENT_URI, + WatchNextProgram.PROJECTION, + null, + null, + null, + )?.map(WatchNextProgram::fromCursor) + .orEmpty() + + private fun convert(item: BaseItem): WatchNextProgram = + WatchNextProgram + .Builder() + .apply { + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } else { + setWatchNextType(WatchNextPrograms.WATCH_NEXT_TYPE_NEXT) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setLastEngagementTimeUtcMillis( + dto.userData + ?.lastPlayedDate + ?.atZone(ZoneId.systemDefault()) + ?.toEpochSecond() + ?: Date().time, // TODO + ) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + val imageType = + when (item.type) { + BaseItemKind.EPISODE -> ImageType.THUMB + else -> ImageType.PRIMARY + } + setPosterArtUri(imageUrlService.getItemImageUrl(item, imageType)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + + private suspend fun addOtherChannels() { + val preferences = preferences.data.firstOrNull() + val channelsPrefs = context.getSharedPreferences("channels", Context.MODE_PRIVATE) + + val latest = + api.userLibraryApi + .getLatestMedia( + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = null, + groupItems = true, + limit = + preferences?.homePagePreferences?.maxItemsPerRow + ?: AppPreference.HomePageItems.defaultValue.toInt(), + isPlayed = null, // Server will handle user's preference + ), + ).content + .map { BaseItem(it, true) } + + var channelId = channelsPrefs.getString("latest", null)?.toUri() + if (channelId == null) { + Timber.d("channelId for latest is null") + val channel = + Channel + .Builder() + .apply { + setDisplayName(context.getString(R.string.recently_added)) + setType(Channels.TYPE_PREVIEW) + setAppLinkIntent(Intent(context, MainActivity::class.java)) + }.build() + channelId = + context.contentResolver.insert( + Channels.CONTENT_URI, + channel.toContentValues(), + ) + if (channelId != null) { + channelsPrefs.edit(true) { + putString("latest", channelId.toString()) + } + TvContractCompat.requestChannelBrowsable( + context, + ContentUris.parseId(channelId), + ) + } else { + Timber.w("channelId was null") + throw IllegalStateException("channelId was null") + } + } + val programs = latest.map { convert(channelId, it).toContentValues() }.toTypedArray() + + // Delete & replace + context.contentResolver.delete(TvContractCompat.PreviewPrograms.CONTENT_URI, null, null) + val count = + context.contentResolver.bulkInsert( + TvContractCompat.PreviewPrograms.CONTENT_URI, + programs, + ) + Timber.v("Inserted $count records") + } + + private fun convert( + channelId: Uri, + item: BaseItem, + ): PreviewProgram = + PreviewProgram + .Builder() + .apply { + setChannelId(ContentUris.parseId(channelId)) + + val dto = item.data + setInternalProviderId(item.id.toString()) + + val type = + when (item.type) { + BaseItemKind.SERIES -> WatchNextPrograms.TYPE_TV_SERIES + BaseItemKind.SEASON -> WatchNextPrograms.TYPE_TV_SEASON + BaseItemKind.EPISODE -> WatchNextPrograms.TYPE_TV_EPISODE + BaseItemKind.MOVIE -> WatchNextPrograms.TYPE_MOVIE + else -> WatchNextPrograms.TYPE_CLIP + } + setType(type) + + val resumePosition = dto.userData?.playbackPositionTicks?.ticks + if (resumePosition != null && resumePosition >= 2.minutes) { + // https://developer.android.com/training/tv/discovery/guidelines-app-developers#types-of-content + setLastPlaybackPositionMillis(resumePosition.inWholeMilliseconds.toInt()) + } + dto.runTimeTicks + ?.ticks + ?.inWholeMilliseconds + ?.toInt() + ?.let(::setDurationMillis) + + setTitle(item.title) + setDescription(dto.overview) + if (item.type == BaseItemKind.EPISODE) { + setEpisodeTitle(item.name) + dto.indexNumber?.let(::setEpisodeNumber) + dto.parentIndexNumber?.let(::setSeasonNumber) + } + + setPosterArtAspectRatio(TvContractCompat.PreviewProgramColumns.ASPECT_RATIO_16_9) + setPosterArtUri(imageUrlService.getItemImageUrl(item, ImageType.THUMB)!!.toUri()) + + setIntent( + Intent(context, MainActivity::class.java) + .putExtra(MainActivity.INTENT_ITEM_ID, item.id.toString()) + .putExtra(MainActivity.INTENT_ITEM_TYPE, item.type.serialName) + .apply { + if (item.type == BaseItemKind.EPISODE) { + putExtra( + MainActivity.INTENT_SERIES_ID, + dto.seriesId?.toString(), + ) + putExtra( + MainActivity.INTENT_SEASON_ID, + dto.seasonId?.toString(), + ) + dto.parentIndexNumber?.let { + putExtra( + MainActivity.INTENT_SEASON_NUMBER, + it, + ) + } + dto.indexNumber?.let { + putExtra( + MainActivity.INTENT_EPISODE_NUMBER, + it, + ) + } + } + }, + ) + }.build() + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.tvprovider.TvProviderWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + } + } + +fun Cursor.map(transform: (Cursor) -> T): List = + this.use { + buildList { + if (moveToFirst()) { + do { + add(transform.invoke(this@map)) + } while (moveToNext()) + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index bc39c48e..4dbb0870 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -78,12 +78,13 @@ inline fun List.indexOfFirstOrNull(predicate: (T) -> Boolean): Int? { /** * Try to call [FocusRequester.requestFocus], but catch & log the exception if something is not configured properly */ -fun FocusRequester.tryRequestFocus(): Boolean = +fun FocusRequester.tryRequestFocus(tag: String? = null): Boolean = try { requestFocus() + tag?.let { Timber.v("Request focus tag=%s", tag) } true } catch (ex: IllegalStateException) { - Timber.w(ex, "Failed to request focus") + Timber.w(ex, "Failed to request focus, tag=%s", tag) false } @@ -167,6 +168,26 @@ fun OneTimeLaunchedEffect(runOnceBlock: suspend CoroutineScope.() -> Unit) { } } +/** + * Calls [tryRequestFocus] on the provided [FocusRequester] when this composable launches or resumes + */ +@Composable +fun RequestOrRestoreFocus( + focusRequester: FocusRequester?, + debugKey: String? = null, +) { + if (focusRequester != null) { + LaunchedEffect(Unit) { + debugKey?.let { Timber.v("RequestOrRestoreFocus: %s", it) } + focusRequester.tryRequestFocus() + } +// LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { +// debugKey?.let { Timber.v("RequestOrRestoreFocus onResume: %s", it) } +// focusRequester.tryRequestFocus() +// } + } +} + fun Modifier.enableMarquee(focused: Boolean) = if (focused) { basicMarquee( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index f6d0e467..e4f107b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -1,12 +1,17 @@ package com.github.damontecres.wholphin.ui import androidx.annotation.StringRes +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType +import timber.log.Timber import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException import java.time.format.FormatStyle import java.util.Locale @@ -23,6 +28,16 @@ fun formatDateTime(dateTime: LocalDateTime): String = DateFormatter.format(dateT fun formatDate(dateTime: LocalDate): String = DateFormatter.format(dateTime) +fun toLocalDate(date: String?): LocalDate? = + date?.let { + try { + LocalDate.parse(it) + } catch (_: DateTimeParseException) { + Timber.w("Could not parse date: %s", date) + null + } + } + /** * If the item has season & episode info, format as `S# E#` */ @@ -140,3 +155,31 @@ val MediaSegmentType.skipStringRes: Int MediaSegmentType.OUTRO -> R.string.skip_segment_outro MediaSegmentType.INTRO -> R.string.skip_segment_intro } + +fun AnnotatedString.Builder.dot() = append(" \u2022 ") + +fun listToDotString( + strings: List, + communityRating: Float?, + criticRating: Float?, +): AnnotatedString = + buildAnnotatedString { + strings.forEachIndexed { index, string -> + append(string) + if (index != strings.lastIndex) dot() + communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index beafccb2..54f564f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -21,18 +21,23 @@ val LocalImageUrlService = /** * Colors not associated with the theme */ -sealed class AppColors private constructor() { - companion object { - val TransparentBlack25 = Color(0x40000000) - val TransparentBlack50 = Color(0x80000000) - val TransparentBlack75 = Color(0xBF000000) +object AppColors { + val TransparentBlack25 = Color(0x40000000) + val TransparentBlack50 = Color(0x80000000) + val TransparentBlack75 = Color(0xBF000000) - val DarkGreen = Color(0xFF114000) - val DarkRed = Color(0xFF400000) - val DarkCyan = Color(0xFF21556E) - val DarkPurple = Color(0xFF261370) + val DarkGreen = Color(0xFF114000) + val DarkRed = Color(0xFF400000) + val DarkCyan = Color(0xFF21556E) + val DarkPurple = Color(0xFF261370) - val GoldenYellow = Color(0xFFDAB440) + val GoldenYellow = Color(0xFFDAB440) + + object Discover { + val Blue = Color(37, 99, 235) + val Purple = Color(147, 51, 234) + val Green = Color(74, 222, 128) + val Yellow = Color(234, 179, 8) } } @@ -51,6 +56,7 @@ val DefaultItemFields = ItemFields.SORT_NAME, ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, + ItemFields.MEDIA_SOURCE_COUNT, ) /** @@ -63,10 +69,12 @@ val SlimItemFields = ItemFields.CHILD_COUNT, ItemFields.OVERVIEW, ItemFields.SORT_NAME, + ItemFields.MEDIA_SOURCE_COUNT, ) object Cards { val height2x3 = 172.dp + val heightEpisode = height2x3 * .75f val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt new file mode 100644 index 00000000..4e1d0e86 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -0,0 +1,294 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import kotlinx.coroutines.delay + +@Composable +@NonRestartableComposable +fun DiscoverItemCard( + item: DiscoverItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + showOverlay: Boolean, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + var focusedAfterDelay by remember { mutableStateOf(false) } + + val hideOverlayDelay = 500L + if (focused) { + LaunchedEffect(Unit) { + delay(hideOverlayDelay) + if (focused) { + focusedAfterDelay = true + } else { + focusedAfterDelay = false + } + } + } else { + focusedAfterDelay = false + } + val width = Cards.height2x3 * AspectRatios.TALL + val height = Dp.Unspecified * (1f / AspectRatios.TALL) + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.size(width, height), + ) { + Card( + modifier = + Modifier + .size(Dp.Unspecified, Cards.height2x3) + .aspectRatio(AspectRatios.TALL), + onClick = onClick, + onLongClick = onLongClick, + interactionSource = interactionSource, + colors = + CardDefaults.colors( + containerColor = Color.Transparent, + ), + ) { + Box( + modifier = + Modifier + .fillMaxSize(), + ) { + ItemCardImage( + imageUrl = item?.posterUrl, + name = item?.title, + showOverlay = false, + favorite = false, + watched = false, + unwatchedCount = 0, + watchedPercent = null, + numberOfVersions = -1, + useFallbackText = false, + contentScale = ContentScale.FillBounds, + modifier = + Modifier + .fillMaxSize(), + ) + when (item?.availability) { + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + SeerrAvailability.AVAILABLE, + -> { + AvailableIndicator(Modifier.align(Alignment.TopEnd)) + } + + else -> {} + } + if (showOverlay) { + val color = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> AppColors.Discover.Blue + SeerrItemType.TV -> AppColors.Discover.Purple + SeerrItemType.PERSON -> AppColors.Discover.Green + SeerrItemType.UNKNOWN -> Color.Black + null -> Color.Black + }.copy(alpha = .8f) + } + val text = + remember(item?.type) { + when (item?.type) { + SeerrItemType.MOVIE -> R.plurals.movies + SeerrItemType.TV -> R.plurals.tv_shows + SeerrItemType.PERSON -> R.plurals.people + SeerrItemType.UNKNOWN -> null + null -> null + } + } + text?.let { + Text( + text = pluralStringResource(it, 1), + style = MaterialTheme.typography.bodySmall, + fontSize = 10.sp, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .background( + color = color, + shape = CircleShape, + ).padding(4.dp), + ) + } + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = item?.title ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = item?.releaseDate?.year?.toString() ?: "", + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} + +@Composable +fun PendingIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = AppColors.Discover.Yellow, + shape = CircleShape, + ).background( + color = Color.White.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_bell), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = AppColors.Discover.Yellow, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun AvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Text( + text = stringResource(R.string.fa_check), + fontFamily = FontAwesome, + fontSize = 10.sp, + color = Color.White, + modifier = Modifier.align(Alignment.Center), + ) + } +} + +@Composable +fun PartiallyAvailableIndicator(modifier: Modifier = Modifier) { + Box( + modifier = + modifier + .padding(4.dp) + .border( + width = .5.dp, + color = Color.White, + shape = CircleShape, + ).background( + color = AppColors.Discover.Green.copy(alpha = .85f), + shape = CircleShape, + ).size(16.dp), + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(width = 10.dp, height = 2.dp) + .background( + color = Color.White, + shape = CircleShape, + ), + ) + } +} + +@PreviewTvSpec +@Composable +private fun Preview() { + WholphinTheme { + Column { + PendingIndicator() + AvailableIndicator() + PartiallyAvailableIndicator() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index 8cc3ad12..e4ffa5ee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -97,6 +97,7 @@ fun EpisodeCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt index 39750da3..adfff582 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ExtrasRow.kt @@ -68,6 +68,7 @@ fun ExtrasRow( isPlayed = false, unplayedItemCount = -1, playedPercentage = -1.0, + numberOfVersions = -1, aspectRatio = AspectRatios.FOUR_THREE, // TODO ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index c2e863c3..73cf5ba3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -19,7 +19,6 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults @@ -34,7 +33,6 @@ import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.setup.rememberIdColor import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import timber.log.Timber import java.util.UUID @Composable @@ -77,7 +75,7 @@ fun GenreCard( contentDescription = null, modifier = Modifier - .alpha(.6f) + .alpha(.75f) .aspectRatio(AspectRatios.WIDE) .fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 1bc35a08..7b653865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -91,6 +91,7 @@ fun GridCard( watched = dto?.userData?.played ?: false, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, watchedPercent = dto?.userData?.playedPercentage, + numberOfVersions = dto?.mediaSourceCount ?: 0, useFallbackText = false, contentScale = imageContentScale, modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt index 9254fbd3..8964477a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemCardImage.kt @@ -58,6 +58,7 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, imageType: ImageType = ImageType.PRIMARY, useFallbackText: Boolean = true, @@ -86,6 +87,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = modifier.onLayoutRectChanged( throttleMillis = 100, @@ -107,9 +109,17 @@ fun ItemCardImage( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, useFallbackText: Boolean = true, contentScale: ContentScale = ContentScale.Fit, + fallback: @Composable BoxScope.() -> Unit = { + ItemCardImageFallback( + name = name, + useFallbackText = useFallbackText, + modifier = Modifier, + ) + }, ) { var imageError by remember(imageUrl) { mutableStateOf(false) } Box( @@ -131,11 +141,7 @@ fun ItemCardImage( .align(Alignment.TopCenter), ) } else { - ItemCardImageFallback( - name = name, - useFallbackText = useFallbackText, - modifier = Modifier, - ) + fallback.invoke(this) } if (showOverlay) { ItemCardImageOverlay( @@ -143,6 +149,7 @@ fun ItemCardImage( watched = watched, unwatchedCount = unwatchedCount, watchedPercent = watchedPercent, + numberOfVersions = numberOfVersions, modifier = Modifier, ) } @@ -198,20 +205,45 @@ fun ItemCardImageOverlay( watched: Boolean, unwatchedCount: Int, watchedPercent: Double?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) { Box(modifier = modifier.fillMaxSize()) { - if (favorite) { - Text( - modifier = - Modifier - .align(Alignment.TopStart) - .padding(8.dp), - color = colorResource(android.R.color.holo_red_light), - text = stringResource(R.string.fa_heart), - fontSize = 20.sp, - fontFamily = FontAwesome, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(4.dp), + ) { + if (numberOfVersions > 1) { + Box( + modifier = + Modifier + .background( + AppColors.TransparentBlack50, + shape = RoundedCornerShape(25), + ), + ) { + Text( + text = numberOfVersions.toString(), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyMedium, +// fontSize = 16.sp, + modifier = Modifier.padding(4.dp), + ) + } + } + if (favorite) { + Text( + color = colorResource(android.R.color.holo_red_light), + text = stringResource(R.string.fa_heart), + fontSize = 20.sp, + fontFamily = FontAwesome, + modifier = Modifier, + ) + } } Row( horizontalArrangement = Arrangement.spacedBy(4.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 618b9003..83166fa7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -9,21 +10,19 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.util.FocusPair +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus @Composable fun ItemRow( @@ -39,16 +38,20 @@ fun ItemRow( onLongClick: () -> Unit, ) -> Unit, modifier: Modifier = Modifier, - focusPair: FocusPair? = null, - cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, horizontalPadding: Dp = 16.dp, ) { val state = rememberLazyListState() val firstFocus = remember { FocusRequester() } - var focusedIndex by remember { mutableIntStateOf(focusPair?.column ?: 0) } + val focusRequester = remember { FocusRequester() } + var position by rememberInt() Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, + modifier = + modifier.focusProperties { + onEnter = { + focusRequester.tryRequestFocus() + } + }, ) { Text( text = title, @@ -62,67 +65,31 @@ fun ItemRow( modifier = Modifier .fillMaxWidth() - .focusRestorer(focusPair?.focusRequester ?: firstFocus), + .focusGroup() + .focusRestorer(firstFocus) + .focusRequester(focusRequester), ) { itemsIndexed(items) { index, item -> val cardModifier = - if (index == 0 && focusPair == null) { + if (index == position) { Modifier.focusRequester(firstFocus) } else { - if (focusPair != null && focusPair.column == index) { - Modifier.focusRequester(focusPair.focusRequester) - } else { - Modifier - } - }.onFocusChanged { - if (it.isFocused) { - focusedIndex = index - } - cardOnFocus?.invoke(it.isFocused, index) + Modifier } cardContent.invoke( index, item, cardModifier, - { if (item != null) onClickItem.invoke(index, item) }, - { if (item != null) onLongClickItem.invoke(index, item) }, + { + position = index + if (item != null) onClickItem.invoke(index, item) + }, + { + position = index + if (item != null) onLongClickItem.invoke(index, item) + }, ) } } } } - -@Composable -fun BannerItemRow( - title: String, - items: List, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - modifier: Modifier = Modifier, - focusPair: FocusPair? = null, - cardOnFocus: ((isFocused: Boolean, index: Int) -> Unit)? = null, - aspectRatioOverride: Float? = null, -) = ItemRow( - title = title, - items = items, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - modifier = modifier, - cardContent = { index, item, modifier, onClick, onLongClick -> - BannerCard( - name = title, - item = item, - aspectRatio = - aspectRatioOverride ?: item?.aspectRatio ?: AspectRatios.WIDE, - cornerText = item?.data?.indexNumber?.let { "E$it" }, - played = item?.data?.userData?.played ?: false, - playPercent = item?.data?.userData?.playedPercentage ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = modifier, - interactionSource = null, - ) - }, - focusPair = focusPair, - cardOnFocus = cardOnFocus, -) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 83d72010..47fd4e94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -1,37 +1,77 @@ package com.github.damontecres.wholphin.ui.cards import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.Border import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.AspectRatios +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.enableMarquee +import com.github.damontecres.wholphin.ui.theme.WholphinTheme import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PersonKind /** * A Card for a [Person] such as an actor or director */ @Composable fun PersonCard( - item: Person, + person: Person, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = PersonCard( + name = person.name, + role = person.role, + imageUrl = person.imageUrl, + favorite = person.favorite, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun PersonCard( + name: String?, + role: String?, + imageUrl: String?, + favorite: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -65,24 +105,62 @@ fun PersonCard( onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, + shape = CardDefaults.shape(CircleShape), + border = + CardDefaults.border( + focusedBorder = + Border( + border = + BorderStroke( + width = 3.dp, + color = MaterialTheme.colorScheme.border, + ), + shape = CircleShape, + ), + ), colors = CardDefaults.colors( containerColor = Color.Transparent, ), ) { ItemCardImage( - imageUrl = item.imageUrl, - name = item.name, - showOverlay = true, - favorite = item.favorite, + imageUrl = imageUrl, + name = name, + showOverlay = false, + favorite = favorite, watched = false, unwatchedCount = -1, + numberOfVersions = -1, watchedPercent = null, - useFallbackText = false, + useFallbackText = true, + contentScale = ContentScale.Crop, + fallback = { + Box( + modifier = + modifier + .background(MaterialTheme.colorScheme.surfaceVariant) + .fillMaxSize() + .align(Alignment.Center), + ) { + Text( + text = stringResource(R.string.fa_user), + fontFamily = FontAwesome, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 64.sp, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth() + .align(Alignment.Center), + ) + } + }, modifier = Modifier .fillMaxWidth() - .aspectRatio(AspectRatios.TALL), // TODO, + .aspectRatio(AspectRatios.SQUARE) + .clip(CircleShape), ) } Column( @@ -93,7 +171,7 @@ fun PersonCard( .fillMaxWidth(), ) { Text( - text = item.name ?: "", + text = name ?: "", maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -102,9 +180,9 @@ fun PersonCard( .padding(horizontal = 4.dp) .enableMarquee(focusedAfterDelay), ) - item.role?.let { + role?.let { Text( - text = item.role, + text = role, maxLines = 1, textAlign = TextAlign.Center, modifier = @@ -117,3 +195,24 @@ fun PersonCard( } } } + +@PreviewTvSpec +@Composable +private fun PersonCardPreview() { + WholphinTheme { + PersonCard( + person = + Person( + id = UUID.randomUUID(), + name = "John Smith", + role = "Actor", + type = PersonKind.ACTOR, + imageUrl = null, + favorite = false, + ), + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(personRowCardWidth), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index b13ca932..2895db83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -11,7 +11,9 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -21,8 +23,10 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.rememberInt @Composable fun PersonRow( @@ -31,6 +35,57 @@ fun PersonRow( modifier: Modifier = Modifier, @StringRes title: Int = R.string.people, onLongClick: ((Int, Person) -> Unit)? = null, +) { + val firstFocus = remember { FocusRequester() } + var position by rememberInt() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = rememberLazyListState(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + Modifier + .padding(start = 16.dp) + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(people) { index, person -> + PersonCard( + person = person, + onClick = { + position = index + onClick.invoke(person) + }, + onLongClick = { + position = index + onLongClick?.invoke(index, person) + }, + modifier = + Modifier + .width(personRowCardWidth) + .ifElse(index == position, Modifier.focusRequester(firstFocus)) + .animateItem(), + ) + } + } + } +} + +@Composable +fun DiscoverPersonRow( + people: List, + onClick: (DiscoverItem) -> Unit, + modifier: Modifier = Modifier, + @StringRes title: Int = R.string.people, + onLongClick: ((Int, DiscoverItem) -> Unit)? = null, ) { val firstFocus = remember { FocusRequester() } Column( @@ -52,14 +107,17 @@ fun PersonRow( .fillMaxWidth() .focusRestorer(firstFocus), ) { - itemsIndexed(people) { index, item -> + itemsIndexed(people) { index, person -> PersonCard( - item = item, - onClick = { onClick.invoke(item) }, - onLongClick = { onLongClick?.invoke(index, item) }, + name = person.title, + role = person.subtitle, + imageUrl = person.posterUrl, + favorite = false, + onClick = { onClick.invoke(person) }, + onLongClick = { onLongClick?.invoke(index, person) }, modifier = Modifier - .width(120.dp) + .width(personRowCardWidth) .ifElse(index == 0, Modifier.focusRequester(firstFocus)) .animateItem(), ) @@ -67,3 +125,5 @@ fun PersonRow( } } } + +val personRowCardWidth = 108.dp diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index 243bb6d6..e3e6059e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -88,6 +88,7 @@ fun SeasonCard( isPlayed = item?.data?.userData?.played ?: false, unplayedItemCount = item?.data?.userData?.unplayedItemCount ?: 0, playedPercentage = item?.data?.userData?.playedPercentage ?: 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, onClick = onClick, onLongClick = onLongClick, modifier = modifier, @@ -112,6 +113,7 @@ fun SeasonCard( isPlayed: Boolean, unplayedItemCount: Int, playedPercentage: Double, + numberOfVersions: Int, onClick: () -> Unit, onLongClick: () -> Unit, modifier: Modifier = Modifier, @@ -172,6 +174,7 @@ fun SeasonCard( watched = isPlayed, unwatchedCount = unplayedItemCount, watchedPercent = playedPercentage, + numberOfVersions = numberOfVersions, useFallbackText = false, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 8692d447..3fa8ffa5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -67,7 +69,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GridCard import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -87,15 +89,17 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -114,13 +118,13 @@ import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.TreeSet import java.util.UUID -import javax.inject.Inject import kotlin.time.Duration -@HiltViewModel +@HiltViewModel(assistedFactory = CollectionFolderViewModel.Factory::class) class CollectionFolderViewModel - @Inject + @AssistedInject constructor( + private val savedStateHandle: SavedStateHandle, api: ApiClient, @param:ApplicationContext private val context: Context, private val serverRepository: ServerRepository, @@ -128,63 +132,75 @@ class CollectionFolderViewModel private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, val navigationManager: NavigationManager, + @Assisted itemId: String, + @Assisted initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") private val recursive: Boolean, + @Assisted private val collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") private val useSeriesForPrimary: Boolean, + @Assisted defaultViewOptions: ViewOptions, ) : ItemViewModel(api) { - val loading = MutableLiveData(LoadingState.Loading) + @AssistedFactory + interface Factory { + fun create( + itemId: String, + initialSortAndDirection: SortAndDirection?, + @Assisted("recursive") recursive: Boolean, + collectionFilter: CollectionFolderFilter, + @Assisted("useSeriesForPrimary") useSeriesForPrimary: Boolean, + defaultViewOptions: ViewOptions, + ): CollectionFolderViewModel + } + + val loading = MutableLiveData>>(DataLoadingState.Loading) val backgroundLoading = MutableLiveData(LoadingState.Loading) - val pager = MutableLiveData>(listOf()) val sortAndDirection = MutableLiveData() val filter = MutableLiveData(GetItemsFilter()) val viewOptions = MutableLiveData() - private var useSeriesForPrimary: Boolean = true - private lateinit var collectionFilter: CollectionFolderFilter - - fun init( - itemId: String, - initialSortAndDirection: SortAndDirection?, - recursive: Boolean, - collectionFilter: CollectionFolderFilter, - useSeriesForPrimary: Boolean, - defaultViewOptions: ViewOptions, - ): Job = - viewModelScope.launch( - LoadingExceptionHandler( - loading, - context.getString(R.string.error_loading_collection, itemId), - ) + Dispatchers.IO, - ) { - this@CollectionFolderViewModel.collectionFilter = collectionFilter - this@CollectionFolderViewModel.useSeriesForPrimary = useSeriesForPrimary - this@CollectionFolderViewModel.itemId = itemId - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } - - val libraryDisplayInfo = - serverRepository.currentUser.value?.let { user -> - libraryDisplayInfoDao.getItem(user, itemId) - } - this@CollectionFolderViewModel.viewOptions.setValueOnMain( - libraryDisplayInfo?.viewOptions ?: defaultViewOptions, - ) - - val sortAndDirection = - if (collectionFilter.useSavedLibraryDisplayInfo) { - libraryDisplayInfo?.sortAndDirection - } else { - null - } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT - - val filterToUse = - if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { - collectionFilter.filter.merge(libraryDisplayInfo.filter) - } else { - collectionFilter.filter - } - - loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + var position: Int + get() = savedStateHandle.get("position") ?: 0 + set(value) { + savedStateHandle["position"] = value } + init { + viewModelScope.launchIO { + super.itemId = itemId + try { + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } + + val libraryDisplayInfo = + serverRepository.currentUser.value?.let { user -> + libraryDisplayInfoDao.getItem(user, itemId) + } + this@CollectionFolderViewModel.viewOptions.setValueOnMain( + libraryDisplayInfo?.viewOptions ?: defaultViewOptions, + ) + + val sortAndDirection = + if (collectionFilter.useSavedLibraryDisplayInfo) { + libraryDisplayInfo?.sortAndDirection + } else { + null + } ?: initialSortAndDirection ?: SortAndDirection.DEFAULT + + val filterToUse = + if (collectionFilter.useSavedLibraryDisplayInfo && libraryDisplayInfo?.filter != null) { + collectionFilter.filter.merge(libraryDisplayInfo.filter) + } else { + collectionFilter.filter + } + + loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + } catch (ex: Exception) { + Timber.e(ex, "Error during init") + loading.setValueOnMain(DataLoadingState.Error(ex)) + } + } + } + private fun saveLibraryDisplayInfo( newFilter: GetItemsFilter = this.filter.value!!, newSort: SortAndDirection = this.sortAndDirection.value!!, @@ -192,7 +208,7 @@ class CollectionFolderViewModel ) { if (collectionFilter.useSavedLibraryDisplayInfo) { serverRepository.currentUser.value?.let { user -> - viewModelScope.launch(Dispatchers.IO) { + viewModelScope.launchIO { val libraryDisplayInfo = LibraryDisplayInfo( userId = user.rowId, @@ -252,8 +268,7 @@ class CollectionFolderViewModel viewModelScope.launch(Dispatchers.IO) { withContext(Dispatchers.Main) { if (resetState) { - pager.value = listOf() - loading.value = LoadingState.Loading + loading.value = DataLoadingState.Loading } backgroundLoading.value = LoadingState.Loading this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection @@ -264,8 +279,7 @@ class CollectionFolderViewModel createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() if (newPager.isNotEmpty()) newPager.getBlocking(0) withContext(Dispatchers.Main) { - pager.value = newPager - loading.value = LoadingState.Success + loading.value = DataLoadingState.Success(newPager) backgroundLoading.value = LoadingState.Success } } catch (ex: Exception) { @@ -276,8 +290,7 @@ class CollectionFolderViewModel filter, ) withContext(Dispatchers.Main) { - loading.value = LoadingState.Error(ex) - pager.value = listOf() + loading.value = DataLoadingState.Error(ex) } } } @@ -288,50 +301,14 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ): ApiRequestPager { - val item = item.value - return when (filter.override) { + ): ApiRequestPager = + when (filter.override) { GetItemsFilterOverride.NONE -> { - val includeItemTypes = - item - ?.data - ?.collectionType - ?.baseItemKinds - .orEmpty() val request = - filter.applyTo( - GetItemsRequest( - parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), - includeItemTypes = includeItemTypes, - recursive = recursive, - excludeItemIds = item?.let { listOf(item.id) }, - sortBy = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.sort) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(ItemSortBy.SORT_NAME) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(ItemSortBy.PRODUCTION_YEAR) - } - } - }, - sortOrder = - buildList { - if (sortAndDirection.sort != ItemSortBy.DEFAULT) { - add(sortAndDirection.direction) - if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { - add(SortOrder.ASCENDING) - } - if (item?.data?.collectionType == CollectionType.MOVIES) { - add(SortOrder.ASCENDING) - } - } - }, - fields = SlimItemFields, - ), + createGetItemsRequest( + sortAndDirection = sortAndDirection, + recursive = recursive, + filter = filter, ) val newPager = ApiRequestPager( @@ -362,6 +339,55 @@ class CollectionFolderViewModel newPager } } + + private fun createGetItemsRequest( + sortAndDirection: SortAndDirection, + recursive: Boolean, + filter: GetItemsFilter, + ): GetItemsRequest { + val item = item.value + val includeItemTypes = + item + ?.data + ?.collectionType + ?.baseItemKinds + .orEmpty() + val request = + filter.applyTo( + GetItemsRequest( + parentId = item?.id, + enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + includeItemTypes = includeItemTypes, + recursive = recursive, + excludeItemIds = item?.let { listOf(item.id) }, + sortBy = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.sort) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(ItemSortBy.SORT_NAME) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(ItemSortBy.PRODUCTION_YEAR) + } + } + }, + sortOrder = + buildList { + if (sortAndDirection.sort != ItemSortBy.DEFAULT) { + add(sortAndDirection.direction) + if (sortAndDirection.sort != ItemSortBy.SORT_NAME) { + add(SortOrder.ASCENDING) + } + if (item?.data?.collectionType == CollectionType.MOVIES) { + add(SortOrder.ASCENDING) + } + } + }, + fields = SlimItemFields, + ), + ) + return request } suspend fun getFilterOptionValues(filterOption: ItemFilterBy<*>): List = @@ -441,26 +467,25 @@ class CollectionFolderViewModel suspend fun positionOfLetter(letter: Char): Int? = withContext(Dispatchers.IO) { - item.value?.let { item -> - val includeItemTypes = - when (item.data.collectionType) { - CollectionType.MOVIES -> listOf(BaseItemKind.MOVIE) - CollectionType.TVSHOWS -> listOf(BaseItemKind.SERIES) - CollectionType.HOMEVIDEOS -> listOf(BaseItemKind.VIDEO) - else -> listOf() - } - val request = - GetItemsRequest( - parentId = item.id, - includeItemTypes = includeItemTypes, - nameLessThan = letter.toString(), - limit = 0, - enableTotalRecordCount = true, - recursive = true, - ) - val result by GetItemsRequestHandler.execute(api, request) - result.totalRecordCount + val sort = sortAndDirection.value + val filter = filter.value + if (sort == null || filter == null) { + return@withContext null } + val request = + createGetItemsRequest( + sortAndDirection = sort, + recursive = recursive, + filter = filter, + ).copy( + enableImageTypes = null, + fields = null, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + ) + val result by GetItemsRequestHandler.execute(api, request) + result.totalRecordCount } fun setWatched( @@ -469,7 +494,9 @@ class CollectionFolderViewModel played: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setWatched(itemId, played) - (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } fun setFavorite( @@ -478,7 +505,9 @@ class CollectionFolderViewModel favorite: Boolean, ) = viewModelScope.launch(ExceptionHandler() + Dispatchers.IO) { favoriteWatchManager.setFavorite(itemId, favorite) - (pager.value as? ApiRequestPager<*>)?.refreshItem(position, itemId) + (loading.value as? DataLoadingState.Success)?.let { + (it.data as? ApiRequestPager<*>)?.refreshItem(position, itemId) + } } fun updateBackdrop(item: BaseItem) { @@ -504,11 +533,13 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, + viewModelKey: String? = itemId.toServerString(), initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, ) = CollectionFolderGrid( preferences, itemId.toServerString(), @@ -517,6 +548,7 @@ fun CollectionFolderGrid( onClickItem, sortOptions, playEnabled, + viewModelKey = viewModelKey, defaultViewOptions = defaultViewOptions, modifier = modifier, initialSortAndDirection = initialSortAndDirection, @@ -524,6 +556,7 @@ fun CollectionFolderGrid( positionCallback = positionCallback, useSeriesForPrimary = useSeriesForPrimary, filterOptions = filterOptions, + focusRequesterOnEmpty = focusRequesterOnEmpty, ) @Composable @@ -537,31 +570,34 @@ fun CollectionFolderGrid( playEnabled: Boolean, defaultViewOptions: ViewOptions, modifier: Modifier = Modifier, - viewModel: CollectionFolderViewModel = hiltViewModel(key = itemId), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModelKey: String? = itemId, initialSortAndDirection: SortAndDirection? = null, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, useSeriesForPrimary: Boolean = true, filterOptions: List> = DefaultFilterOptions, + focusRequesterOnEmpty: FocusRequester? = null, + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), + viewModel: CollectionFolderViewModel = + hiltViewModel( + key = viewModelKey, + ) { + it.create( + itemId = itemId, + initialSortAndDirection = initialSortAndDirection, + recursive = recursive, + collectionFilter = initialFilter, + useSeriesForPrimary = useSeriesForPrimary, + defaultViewOptions = defaultViewOptions, + ) + }, ) { val context = LocalContext.current - OneTimeLaunchedEffect { - viewModel.init( - itemId, - initialSortAndDirection, - recursive, - initialFilter, - useSeriesForPrimary, - defaultViewOptions, - ) - } val sortAndDirection by viewModel.sortAndDirection.observeAsState(SortAndDirection.DEFAULT) val filter by viewModel.filter.observeAsState(initialFilter.filter) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val backgroundLoading by viewModel.backgroundLoading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() - val pager by viewModel.pager.observeAsState() val viewOptions by viewModel.viewOptions.observeAsState(defaultViewOptions) var moreDialog by remember { mutableStateOf>(Optional.absent()) } @@ -569,90 +605,91 @@ fun CollectionFolderGrid( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { - is LoadingState.Error -> { - ErrorMessage(state) - } - - LoadingState.Loading, - LoadingState.Pending, + DataLoadingState.Loading, + DataLoadingState.Pending, -> { LoadingPage() } - LoadingState.Success -> { - pager?.let { pager -> - val title = - initialFilter.nameOverride - ?: item?.name - ?: item?.data?.collectionType?.name - ?: stringResource(R.string.collection) - Box(modifier = modifier) { - CollectionFolderGridContent( - preferences = preferences, - item = item, - title = title, - pager = pager, - sortAndDirection = sortAndDirection!!, - modifier = Modifier.fillMaxSize(), - onClickItem = onClickItem, - onLongClickItem = { position, item -> - moreDialog.makePresent(PositionItem(position, item)) - }, - onSortChange = { - viewModel.onSortChange(it, recursive, filter) - }, - filterOptions = filterOptions, - currentFilter = filter, - onFilterChange = { - viewModel.onFilterChange(it, recursive) - }, - getPossibleFilterValues = { - viewModel.getFilterOptionValues(it) - }, - showTitle = showTitle, - sortOptions = sortOptions, - positionCallback = positionCallback, - letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onSaveViewOptions = { viewModel.saveViewOptions(it) }, - onChangeBackdrop = viewModel::updateBackdrop, - playEnabled = playEnabled, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) - }, - onClickPlayAll = { shuffle -> - itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) - } - }, - ) + is DataLoadingState.Error, + is DataLoadingState.Success<*>, + -> { + val title = + initialFilter.nameOverride + ?: item?.name + ?: item?.data?.collectionType?.name + ?: stringResource(R.string.collection) + Box(modifier = modifier) { + CollectionFolderGridContent( + preferences = preferences, + initialPosition = viewModel.position, + item = item, + title = title, + loadingState = state as DataLoadingState>, + sortAndDirection = sortAndDirection!!, + modifier = Modifier.fillMaxSize(), + focusRequesterOnEmpty = focusRequesterOnEmpty, + onClickItem = onClickItem, + onLongClickItem = { position, item -> + moreDialog.makePresent(PositionItem(position, item)) + }, + onSortChange = { + viewModel.onSortChange(it, recursive, filter) + }, + filterOptions = filterOptions, + currentFilter = filter, + onFilterChange = { + viewModel.onFilterChange(it, recursive) + }, + getPossibleFilterValues = { + viewModel.getFilterOptionValues(it) + }, + showTitle = showTitle, + sortOptions = sortOptions, + positionCallback = { columns, position -> + viewModel.position = position + positionCallback?.invoke(columns, position) + }, + letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onSaveViewOptions = { viewModel.saveViewOptions(it) }, + onChangeBackdrop = viewModel::updateBackdrop, + playEnabled = playEnabled, + onClickPlay = { _, item -> + viewModel.navigationManager.navigateTo(Destination.Playback(item)) + }, + onClickPlayAll = { shuffle -> + itemId.toUUIDOrNull()?.let { + viewModel.navigationManager.navigateTo( + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ), + ) + } + }, + ) - AnimatedVisibility( - backgroundLoading == LoadingState.Loading, - modifier = - Modifier - .align(Alignment.Center) - .padding(16.dp), - ) { - CircularProgress( - Modifier - .background( - MaterialTheme.colorScheme.background.copy(alpha = .25f), - shape = CircleShape, - ).size(64.dp) - .padding(4.dp), - ) - } + AnimatedVisibility( + backgroundLoading == LoadingState.Loading, + modifier = + Modifier + .align(Alignment.Center) + .padding(16.dp), + ) { + CircularProgress( + Modifier + .background( + MaterialTheme.colorScheme.background.copy(alpha = .25f), + shape = CircleShape, + ).size(64.dp) + .padding(4.dp), + ) } } } @@ -713,7 +750,7 @@ fun CollectionFolderGridContent( preferences: UserPreferences, item: BaseItem?, title: String, - pager: List, + loadingState: DataLoadingState>, sortAndDirection: SortAndDirection, onClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -728,25 +765,35 @@ fun CollectionFolderGridContent( onClickPlayAll: (shuffle: Boolean) -> Unit, onClickPlay: (Int, BaseItem) -> Unit, onChangeBackdrop: (BaseItem) -> Unit, + initialPosition: Int, modifier: Modifier = Modifier, showTitle: Boolean = true, positionCallback: ((columns: Int, position: Int) -> Unit)? = null, currentFilter: GetItemsFilter = GetItemsFilter(), filterOptions: List> = listOf(), onFilterChange: (GetItemsFilter) -> Unit = {}, + focusRequesterOnEmpty: FocusRequester? = null, ) { val context = LocalContext.current + val pager = (loadingState as? DataLoadingState.Success)?.data var showHeader by rememberSaveable { mutableStateOf(true) } var showViewOptions by rememberSaveable { mutableStateOf(false) } var viewOptions by remember { mutableStateOf(viewOptions) } + val headerRowFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() } + if (pager?.isNotEmpty() == true) { + RequestOrRestoreFocus(gridFocusRequester) + } else { + LaunchedEffect(Unit) { + (focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus() + } + } var backdropImageUrl by remember { mutableStateOf(null) } - var position by rememberInt(0) - val focusedItem = pager.getOrNull(position) + var position by rememberInt(initialPosition) + val focusedItem = pager?.getOrNull(position) if (viewOptions.showDetails) { LaunchedEffect(focusedItem) { focusedItem?.let(onChangeBackdrop) @@ -759,7 +806,7 @@ fun CollectionFolderGridContent( modifier = Modifier.fillMaxSize(), ) { AnimatedVisibility( - showHeader, + showHeader || loadingState !is DataLoadingState.Success, enter = slideInVertically() + fadeIn(), exit = slideOutVertically() + fadeOut(), ) { @@ -786,7 +833,8 @@ fun CollectionFolderGridContent( modifier = Modifier .padding(start = 16.dp, end = endPadding) - .fillMaxWidth(), + .fillMaxWidth() + .focusRequester(headerRowFocusRequester), ) { if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { Row( @@ -819,7 +867,7 @@ fun CollectionFolderGridContent( ) } } - if (playEnabled) { + if (playEnabled && pager?.isNotEmpty() == true) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, @@ -851,47 +899,62 @@ fun CollectionFolderGridContent( .padding(16.dp), ) } - CardGrid( - pager = pager, - onClickItem = onClickItem, - onLongClickItem = onLongClickItem, - onClickPlay = onClickPlay, - letterPosition = letterPosition, - gridFocusRequester = gridFocusRequester, - showJumpButtons = false, // TODO add preference - showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, - modifier = Modifier.fillMaxSize(), - initialPosition = 0, - positionCallback = { columns, newPosition -> - showHeader = newPosition < columns - position = newPosition - positionCallback?.invoke(columns, newPosition) - }, - cardContent = { item, onClick, onLongClick, mod -> - GridCard( - item = item, - onClick = onClick, - onLongClick = onLongClick, - imageContentScale = viewOptions.contentScale.scale, - imageAspectRatio = viewOptions.aspectRatio.ratio, - imageType = viewOptions.imageType, - showTitle = viewOptions.showTitles, - modifier = mod, + when (val state = loadingState) { + DataLoadingState.Pending, + DataLoadingState.Loading, + -> { + // This shouldn't happen, so just show placeholder + Text("Loading") + } + + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception) + } + + is DataLoadingState.Success> -> { + CardGrid( + pager = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + onClickPlay = onClickPlay, + letterPosition = letterPosition, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, // TODO add preference + showLetterButtons = sortAndDirection.sort == ItemSortBy.SORT_NAME, + modifier = Modifier.fillMaxSize(), + initialPosition = initialPosition, + positionCallback = { columns, newPosition -> + showHeader = newPosition < columns + position = newPosition + positionCallback?.invoke(columns, newPosition) + }, + cardContent = { item, onClick, onLongClick, mod -> + GridCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + imageContentScale = viewOptions.contentScale.scale, + imageAspectRatio = viewOptions.aspectRatio.ratio, + imageType = viewOptions.imageType, + showTitle = viewOptions.showTitles, + modifier = mod, + ) + }, + columns = viewOptions.columns, + spacing = viewOptions.spacing.dp, ) - }, - columns = viewOptions.columns, - spacing = viewOptions.spacing.dp, - ) - AnimatedVisibility(showViewOptions) { - ViewOptionsDialog( - viewOptions = viewOptions, - defaultViewOptions = defaultViewOptions, - onDismissRequest = { - showViewOptions = false - onSaveViewOptions.invoke(viewOptions) - }, - onViewOptionsChange = { viewOptions = it }, - ) + AnimatedVisibility(showViewOptions) { + ViewOptionsDialog( + viewOptions = viewOptions, + defaultViewOptions = defaultViewOptions, + onDismissRequest = { + showViewOptions = false + onSaveViewOptions.invoke(viewOptions) + }, + onViewOptionsChange = { viewOptions = it }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index bfb098d5..85ea35cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -55,6 +56,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -83,6 +85,7 @@ data class DialogItem( val leadingContent: @Composable (BoxScope.() -> Unit)? = null, val trailingContent: @Composable (() -> Unit)? = null, val enabled: Boolean = true, + val selected: Boolean = false, ) : DialogItemEntry { constructor( @StringRes text: Int, @@ -239,47 +242,48 @@ fun DialogPopupContent( ) { val elevatedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation) - LazyColumn( + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier -// .widthIn(min = 520.dp, max = 300.dp) -// .dialogFocusable() .graphicsLayer { this.clip = true this.shape = RoundedCornerShape(28.0.dp) }.drawBehind { drawRect(color = elevatedContainerColor) } .padding(PaddingValues(24.dp)), ) { - stickyHeader { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - items(dialogItems) { - when (it) { - is DialogItemDivider -> { - HorizontalDivider(Modifier.height(16.dp)) - } + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + LazyColumn( + modifier = Modifier, + ) { + items(dialogItems) { + when (it) { + is DialogItemDivider -> { + HorizontalDivider(Modifier.height(16.dp)) + } - is DialogItem -> { - ListItem( - selected = false, - enabled = !waiting && it.enabled, - onClick = { - if (dismissOnClick) { - onDismissRequest.invoke() - } - it.onClick.invoke() - }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, - ) + is DialogItem -> { + ListItem( + selected = it.selected, + enabled = !waiting && it.enabled, + onClick = { + if (dismissOnClick) { + onDismissRequest.invoke() + } + it.onClick.invoke() + }, + headlineContent = it.headlineContent, + overlineContent = it.overlineContent, + supportingContent = it.supportingContent, + leadingContent = it.leadingContent, + trailingContent = it.trailingContent, + modifier = Modifier, + ) + } } } } @@ -500,6 +504,7 @@ fun resourceFor(type: MediaStreamType): Int = fun chooseStream( context: Context, streams: List, + currentIndex: Int?, type: MediaStreamType, onClick: (Int) -> Unit, ): DialogParams = @@ -511,6 +516,10 @@ fun chooseStream( if (type == MediaStreamType.SUBTITLE) { add( DialogItem( + selected = currentIndex == null, + leadingContent = { + SelectedLeadingContent(currentIndex == null) + }, headlineContent = { Text(text = stringResource(R.string.none)) }, @@ -519,15 +528,30 @@ fun chooseStream( onClick = { onClick.invoke(TrackIndex.DISABLED) }, ), ) + add( + DialogItem( + headlineContent = { + Text(text = stringResource(R.string.only_forced_subtitles)) + }, + supportingContent = { + }, + onClick = { onClick.invoke(TrackIndex.ONLY_FORCED) }, + ), + ) } addAll( streams.filter { it.type == type }.mapIndexed { index, stream -> - val title = stream.displayTitle ?: stream.title ?: "" + val simpleStream = SimpleMediaStream.from(context, stream, true) DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, headlineContent = { - Text(text = title) + Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) }, supportingContent = { + if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) }, onClick = { onClick.invoke(stream.index) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt deleted file mode 100644 index 2c9b1d78..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/DotSeparatedRow.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * 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 - * - * https://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 com.github.damontecres.wholphin.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.theme.WholphinTheme - -@Composable -fun DotSeparatedRow( - texts: List, - communityRating: Float? = null, - criticRating: Float? = null, - modifier: Modifier = Modifier, - textStyle: TextStyle = MaterialTheme.typography.titleSmall, -) { - CompositionLocalProvider(LocalTextStyle provides textStyle) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - ) { - texts.forEachIndexed { index, text -> - Text( - text = text, - style = textStyle, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - ) - if (communityRating != null || criticRating != null || index != texts.lastIndex) { - Dot() - } - } - val height = with(LocalDensity.current) { textStyle.fontSize.toDp() } - communityRating?.let { - SimpleStarRating( - communityRating = it, - modifier = Modifier.height(height), - ) - if (criticRating != null) { - Dot() - } - } - criticRating?.let { - TomatoRating(it, Modifier.height(height)) - } - } - } -} - -@Composable -fun Dot(modifier: Modifier = Modifier) { - Box( - modifier = - modifier - .padding(horizontal = 8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onSurface.copy(alpha = 1f)) - .size(4.dp), - ) -} - -@PreviewTvSpec -@Composable -private fun DotSeparatedRowPreview() { - WholphinTheme { - Column { - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = null, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left"), - communityRating = 7.5f, - criticRating = .75f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleMedium, - ) - DotSeparatedRow( - texts = listOf("2025", "1h 48m", "PG-13", "1h 30m left 7.5"), - communityRating = 7.5f, - criticRating = .45f, - modifier = Modifier, - textStyle = MaterialTheme.typography.titleLarge, - ) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 87fc1da6..cdf18969 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -11,6 +11,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -33,6 +37,9 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -51,26 +58,32 @@ import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -@HiltViewModel +@HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel - @Inject + @AssistedInject constructor( private val api: ApiClient, private val imageUrlService: ImageUrlService, private val serverRepository: ServerRepository, val navigationManager: NavigationManager, + @Assisted private val itemId: UUID, + @Assisted private val includeItemTypes: List?, ) : ViewModel() { - private lateinit var itemId: UUID + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + includeItemTypes: List?, + ): GenreViewModel + } val item = MutableLiveData(null) val loading = MutableLiveData(LoadingState.Pending) val genres = MutableLiveData>(listOf()) - fun init(itemId: UUID) { + fun init(cardWidthPx: Int) { loading.value = LoadingState.Loading - this.itemId = itemId viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { val item = api.userLibraryApi.getItem(itemId = itemId).content.let { @@ -82,6 +95,7 @@ class GenreViewModel userId = serverRepository.currentUser.value?.id, parentId = itemId, fields = SlimItemFields, + includeItemTypes = includeItemTypes, ) val genres = GetGenresRequestHandler @@ -90,13 +104,11 @@ class GenreViewModel .map { Genre(it.id, it.name ?: "", null, Color.Black) } -// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init() withContext(Dispatchers.Main) { this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } -// val excludeItemIds = mutableSetOf() - val genreToUrl = ConcurrentHashMap() + val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres .map { genre -> @@ -107,33 +119,29 @@ class GenreViewModel .execute( api, GetItemsRequest( -// excludeItemIds = excludeItemIds, parentId = itemId, recursive = true, limit = 1, sortBy = listOf(ItemSortBy.RANDOM), fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.THUMB), + imageTypes = listOf(ImageType.BACKDROP), imageTypeLimit = 1, - includeItemTypes = - listOf( - BaseItemKind.MOVIE, - BaseItemKind.SERIES, - ), + includeItemTypes = includeItemTypes, genreIds = listOf(genre.id), enableTotalRecordCount = false, ), ).content.items .firstOrNull() if (item != null) { -// excludeItemIds.add(item.id) genreToUrl[genre.id] = imageUrlService.getItemImageUrl( - item.id, - item.type, - null, - false, - ImageType.THUMB, + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = true, + imageType = ImageType.BACKDROP, + imageTags = item.imageTags.orEmpty(), + fillWidth = cardWidthPx, ) } } @@ -164,11 +172,12 @@ class GenreViewModel } data class Genre( - override val id: UUID, + val id: UUID, val name: String, val imageUrl: String?, val color: Color, ) : CardGridItem { + override val gridId: String get() = id.toString() override val playable: Boolean = false override val sortName: String get() = name } @@ -176,11 +185,30 @@ data class Genre( @Composable fun GenreCardGrid( itemId: UUID, + includeItemTypes: List?, modifier: Modifier = Modifier, - viewModel: GenreViewModel = hiltViewModel(), + viewModel: GenreViewModel = + hiltViewModel( + creationCallback = { it.create(itemId, includeItemTypes) }, + ), ) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + remember { + with(density) { + // Grid has 16dp padding on either side & 16dp spacing between 4 cards + // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } OneTimeLaunchedEffect { - viewModel.init(itemId) + viewModel.init(cardWidthPx) } val loading by viewModel.loading.observeAsState(LoadingState.Pending) val genres by viewModel.genres.observeAsState(listOf()) @@ -214,7 +242,11 @@ fun GenreCardGrid( genre.name, item?.title, ).joinToString(" "), - filter = GetItemsFilter(genres = listOf(genre.id)), + filter = + GetItemsFilter( + genres = listOf(genre.id), + includeItemTypes = includeItemTypes, + ), useSavedLibraryDisplayInfo = false, ), recursive = true, @@ -231,7 +263,8 @@ fun GenreCardGrid( initialPosition = 0, positionCallback = { columns, position -> }, - columns = 4, + columns = columns, + spacing = spacing, cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> GenreCard( genre = item, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt deleted file mode 100644 index e25f50be..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/MovieComponents.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.github.damontecres.wholphin.ui.components - -import android.content.Context -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.TimeFormatter -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.timeRemaining -import com.github.damontecres.wholphin.ui.util.LocalClock -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks -import java.time.LocalDateTime - -@Composable -fun MovieQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now = LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.productionYear?.let { add(it.toString()) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -fun MutableList.addRuntimeDetails( - context: Context, - now: LocalDateTime, - dto: BaseItemDto?, -) { - val runtime = dto?.runTimeTicks?.ticks - runtime?.let { duration -> - add(duration.roundMinutes.toString()) - } - dto?.timeRemaining?.roundMinutes?.let { - add("$it left") - } - (dto?.timeRemaining ?: runtime)?.let { remaining -> - val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) - add(context.getString(R.string.ends_at, endTimeStr)) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 0f99f8bc..2856f319 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -21,7 +21,10 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -40,6 +43,7 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.data.SortAndDirection @@ -59,10 +63,12 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + trailers: List?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -134,6 +140,16 @@ fun ExpandablePlayButtons( ) } + if (trailers != null) { + item("trailers") { + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + // More button item("more") { ExpandablePlayButton( @@ -163,10 +179,12 @@ fun ExpandablePlayButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = { onClick.invoke(resume) }, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -213,10 +231,12 @@ fun ExpandableFaButton( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, iconColor: Color = Color.Unspecified, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( onClick = onClick, + enabled = enabled, modifier = modifier.requiredSizeIn( minWidth = MinButtonSize, @@ -251,6 +271,42 @@ fun ExpandableFaButton( } } +@Composable +fun TrailerButton( + trailers: List, + trailerOnClick: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + var showDialog by remember { mutableStateOf(false) } + ExpandableFaButton( + title = + if (trailers.isEmpty()) { + R.string.no_trailers + } else if (trailers.size == 1) { + R.string.play_trailer + } else { + R.string.trailers + }, + iconStringRes = R.string.fa_film, + enabled = trailers.isNotEmpty(), + onClick = { + if (trailers.size == 1) { + trailerOnClick.invoke(trailers.first()) + } else { + showDialog = true + } + }, + modifier = modifier, + ) + if (showDialog) { + TrailerDialog( + onDismissRequest = { showDialog = false }, + trailers = trailers, + onClick = trailerOnClick, + ) + } +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -264,6 +320,8 @@ private fun ExpandablePlayButtonsPreview() { favoriteOnClick = {}, moreOnClick = {}, buttonOnFocusChanged = {}, + trailers = listOf(), + trailerOnClick = {}, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt new file mode 100644 index 00000000..cf5b0697 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -0,0 +1,122 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.TimeFormatter +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.util.LocalClock +import kotlin.time.Duration + +@Composable +fun QuickDetails( + details: AnnotatedString, + timeRemaining: Duration?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val inlineContentMap = + remember(textStyle) { + mapOf( + "star" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = Icons.Filled.Star, + tint = FilledStarColor, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + }, + "rotten" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + "fresh" to + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + tint = Color.Unspecified, + ) + }, + ) + } + + Row(modifier = modifier) { + Text( + text = details, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + inlineContent = inlineContentMap, + maxLines = 1, + modifier = Modifier, + ) + timeRemaining?.let { TimeRemaining(it, textStyle = textStyle) } + } +} + +@Composable +fun TimeRemaining( + remaining: Duration, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, +) { + val context = LocalContext.current + val now by LocalClock.current.now + val remainingStr = + remember(remaining, now) { + val endTimeStr = TimeFormatter.format(now.plusSeconds(remaining.inWholeSeconds)) + buildAnnotatedString { + dot() + append(context.getString(R.string.ends_at, endTimeStr)) + } + } + Text( + text = remainingStr, + style = textStyle, + maxLines = 1, + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index 6e48692a..fa4071b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -12,12 +12,11 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.main.buildCombinedNextUp import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,7 +57,7 @@ class RecommendedTvShowViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, - private val datePlayedService: DatePlayedService, + private val lastestNextUpService: LatestNextUpService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -126,9 +125,7 @@ class RecommendedTvShowViewModel val nextUpItems = nextUpItemsDeferred.await() if (combineNextUp) { val combined = - buildCombinedNextUp( - viewModelScope, - datePlayedService, + lastestNextUpService.buildCombined( resumeItems, nextUpItems, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt new file mode 100644 index 00000000..efc2ddac --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SelectedLeadingContent.kt @@ -0,0 +1,32 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.tv.material3.LocalContentColor + +@Composable +fun BoxScope.SelectedLeadingContent( + selected: Boolean, + modifier: Modifier = Modifier, +) { + if (selected) { + Box( + modifier = + modifier + .padding(horizontal = 4.dp) + .clip(CircleShape) + .align(Alignment.Center) + .background(LocalContentColor.current) + .size(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt index de79bd5a..0ea3d74f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SeriesComponents.kt @@ -1,20 +1,13 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.ui.formatDateTime -import com.github.damontecres.wholphin.ui.roundMinutes -import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.seriesProductionYears -import com.github.damontecres.wholphin.ui.util.LocalClock import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.extensions.ticks @Composable fun SeriesName( @@ -41,7 +34,8 @@ fun EpisodeName( text = episodeName ?: "", color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.headlineSmall, - maxLines = 2, + fontSize = 20.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = modifier, ) @@ -52,52 +46,3 @@ fun EpisodeName( episode: BaseItemDto?, modifier: Modifier = Modifier, ) = EpisodeName(episode?.episodeTitle ?: episode?.name, modifier) - -@Composable -fun EpisodeQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val context = LocalContext.current - val now = LocalClock.current.now - val details = - remember(dto, now) { - buildList { - dto?.seasonEpisode?.let(::add) - dto?.premiereDate?.let { add(formatDateTime(it)) } - addRuntimeDetails(context, now, dto) - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} - -@Composable -fun SeriesQuickDetails( - dto: BaseItemDto?, - modifier: Modifier = Modifier, -) { - val details = - remember(dto) { - buildList { - dto?.seriesProductionYears?.let(::add) - dto?.runTimeTicks?.ticks?.roundMinutes?.let { - add(it.toString()) - } - dto?.officialRating?.let(::add) - } - } - DotSeparatedRow( - texts = details, - communityRating = dto?.communityRating, - criticRating = dto?.criticRating, - textStyle = MaterialTheme.typography.titleSmall, - modifier = modifier, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt index 16e26649..25b51698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TabRow.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned @@ -39,21 +40,25 @@ import androidx.compose.ui.unit.sp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus +import timber.log.Timber @Composable fun TabRow( selectedTabIndex: Int, tabs: List, + focusRequesters: List, onClick: (Int) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() LaunchedEffect(selectedTabIndex) { - state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) + if (selectedTabIndex >= 0) { + state.animateScrollToItem(selectedTabIndex, -(state.layoutInfo.viewportSize.width / 3.5).toInt()) + } } - val focusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } var rowHasFocus by remember { mutableStateOf(false) } LazyRow( state = state, @@ -66,6 +71,7 @@ fun TabRow( onEnter = { // If entering from left or right, use last or first tab // Otherwise use the selected tab + Timber.v("onEnter requestedFocusDirection=$requestedFocusDirection, selectedTabIndex=$selectedTabIndex") val focusRequester = if (requestedFocusDirection == FocusDirection.Left) { focusRequesters.lastOrNull() @@ -181,6 +187,7 @@ private fun TabRowPreview() { TabRow( selectedTabIndex = 1, tabs = listOf("Tab 1", "Tab 2", "Tab 3"), + focusRequesters = listOf(), onClick = {}, ) Tab( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt index 8fa6f60f..f5791e04 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TimeDisplay.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.components import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -13,8 +14,9 @@ import com.github.damontecres.wholphin.ui.util.LocalClock @Composable fun BoxScope.TimeDisplay(modifier: Modifier = Modifier) { + val timeString by LocalClock.current.timeString Text( - text = LocalClock.current.timeString, + text = timeString, fontSize = 18.sp, color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.bodyLarge, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt new file mode 100644 index 00000000..0b17491b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/TrailerDialog.kt @@ -0,0 +1,51 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.Trailer + +@Composable +fun TrailerDialog( + onDismissRequest: () -> Unit, + trailers: List, + onClick: (Trailer) -> Unit, +) { + val trailersStr = stringResource(R.string.play_trailer) + val localStr = stringResource(R.string.local) + val externalStr = stringResource(R.string.external_track) + val params = + remember(trailers) { + DialogParams( + fromLongClick = false, + title = trailersStr, + items = + trailers.map { trailer -> + DialogItem( + headlineContent = { + Text(trailer.name) + }, + supportingContent = { + val subtitle = + when (trailer) { + is LocalTrailer -> localStr + is RemoteTrailer -> trailer.subtitle ?: externalStr + } + Text( + text = subtitle, + ) + }, + onClick = { onClick.invoke(trailer) }, + ) + }, + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt index a557d7f0..7805a95a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VideoStreamDetails.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.ui.components -import android.content.Context import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -31,28 +30,31 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.audioStreamCount import com.github.damontecres.wholphin.ui.playback.embeddedSubtitleCount import com.github.damontecres.wholphin.ui.playback.externalSubtitlesCount import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.StreamFormatting.concatWithSpace +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatVideoRange +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString import com.github.damontecres.wholphin.util.languageName -import com.github.damontecres.wholphin.util.profile.Codec import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStream -import org.jellyfin.sdk.model.api.VideoRange -import org.jellyfin.sdk.model.api.VideoRangeType @Composable @NonRestartableComposable fun VideoStreamDetails( chosenStreams: ChosenStreams?, + numberOfVersions: Int, modifier: Modifier = Modifier, ) = VideoStreamDetails( chosenStreams?.source, chosenStreams?.videoStream, chosenStreams?.audioStream, chosenStreams?.subtitleStream, + numberOfVersions, modifier, ) @@ -62,6 +64,7 @@ fun VideoStreamDetails( videoStream: MediaStream?, audioStream: MediaStream?, subtitleStream: MediaStream?, + numberOfVersions: Int = 0, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -86,13 +89,16 @@ fun VideoStreamDetails( null } val range = formatVideoRange(context, it.videoRange, it.videoRangeType, it.videoDoViTitle) - listOfNotNull( - resName.concatWithSpace(range), - it.codec?.uppercase(), - ) - }.orEmpty() + resName.concatWithSpace(range) + } } - video.forEach { + video?.let { + StreamLabel( + text = it, + count = numberOfVersions, + ) + } + videoStream?.codec?.uppercase()?.let { StreamLabel(it) } @@ -149,35 +155,6 @@ fun VideoStreamDetails( } } -fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" - -// Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 -fun resolutionString( - width: Int, - height: Int, - interlaced: Boolean, -): String = - if (height > width) { - // Vertical video - resolutionString(height, width, interlaced) - } else { - when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" - else -> height.toString() + interlaced(interlaced) - } - } - @Composable fun StreamLabel( text: String, @@ -251,93 +228,3 @@ private fun StreamLabelPreview() { } } } - -fun formatVideoRange( - context: Context, - videoRange: VideoRange?, - type: VideoRangeType?, - doviTitle: String?, -): String? = - when (videoRange) { - VideoRange.UNKNOWN, - VideoRange.SDR, null, - -> { - null - } - - VideoRange.HDR -> { - if (doviTitle.isNotNullOrBlank()) { - context.getString(R.string.dolby_vision) - } else { - when (type) { - VideoRangeType.UNKNOWN, - VideoRangeType.SDR, - null, - -> null - - VideoRangeType.HDR10 -> "HDR10" - - VideoRangeType.HDR10_PLUS -> "HDR10+" - - VideoRangeType.HLG -> "HLG" - - VideoRangeType.DOVI, - VideoRangeType.DOVI_WITH_HDR10, - VideoRangeType.DOVI_WITH_HLG, - VideoRangeType.DOVI_WITH_SDR, - -> context.getString(R.string.dolby_vision) - } - } - } - } - -fun formatAudioCodec( - context: Context, - codec: String?, - profile: String?, -): String? = - when { - profile?.contains("Dolby Atmos", true) == true -> { - context.getString(R.string.dolby_atmos) - } - - profile?.contains("DTS:X", true) == true -> { - "DTS:X" - } - - profile?.contains("DTS:HD", true) == true -> { - "DTS:HD" - } - - else -> { - when (codec?.lowercase()) { - Codec.Audio.TRUEHD -> "TrueHD" - - Codec.Audio.OGG, - Codec.Audio.OPUS, - Codec.Audio.VORBIS, - -> codec.replaceFirstChar { it.uppercase() } - - null -> null - - else -> codec.uppercase() - } - } - } - -fun formatSubtitleCodec(codec: String?): String? = - when (codec?.lowercase()) { - Codec.Subtitle.DVBSUB -> "DVB" - Codec.Subtitle.DVDSUB -> "DVD" - Codec.Subtitle.PGSSUB -> "PGS" - Codec.Subtitle.SUBRIP -> "SRT" - null -> null - else -> codec.uppercase() - } - -fun String?.concatWithSpace(str: String?): String? = - when { - this != null && str != null -> "$this $str" - this == null -> str - else -> this - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt new file mode 100644 index 00000000..d2d6fa32 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceInputManager.kt @@ -0,0 +1,442 @@ +package com.github.damontecres.wholphin.ui.components + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.RecognizerIntent +import android.speech.SpeechRecognizer +import androidx.core.content.ContextCompat +import com.github.damontecres.wholphin.R +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.android.asCoroutineDispatcher +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.io.Closeable +import javax.inject.Inject +import javax.inject.Singleton + +private const val RMS_DB_MIN = -2.0f +private const val RMS_DB_MAX = 10.0f +private const val MAX_RESULTS = 1 +private const val LISTENING_TIMEOUT_MS = 8000L +private const val RECOGNIZER_RECREATE_DELAY_MS = 150L + +private val ERROR_TO_RESOURCE_MAP = + mapOf( + SpeechRecognizer.ERROR_AUDIO to R.string.voice_error_audio, + SpeechRecognizer.ERROR_CLIENT to R.string.voice_error_client, + SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS to R.string.voice_error_permissions, + SpeechRecognizer.ERROR_NETWORK to R.string.voice_error_network, + SpeechRecognizer.ERROR_NETWORK_TIMEOUT to R.string.voice_error_network_timeout, + SpeechRecognizer.ERROR_NO_MATCH to R.string.voice_error_no_match, + SpeechRecognizer.ERROR_RECOGNIZER_BUSY to R.string.voice_error_busy, + SpeechRecognizer.ERROR_SERVER to R.string.voice_error_server, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT to R.string.voice_error_speech_timeout, + SpeechRecognizer.ERROR_TOO_MANY_REQUESTS to R.string.voice_error_busy, + ) + +private val RETRYABLE_ERRORS = + setOf( + SpeechRecognizer.ERROR_NETWORK, + SpeechRecognizer.ERROR_NETWORK_TIMEOUT, + SpeechRecognizer.ERROR_SERVER, + SpeechRecognizer.ERROR_NO_MATCH, + SpeechRecognizer.ERROR_SPEECH_TIMEOUT, + ) + +private fun normalizeRmsDb(rmsdB: Float) = ((rmsdB - RMS_DB_MIN) / (RMS_DB_MAX - RMS_DB_MIN)).coerceIn(0f, 1f) + +sealed interface VoiceInputState { + data object Idle : VoiceInputState + + /** Recognizer is being initialized, not yet ready for speech. */ + data object Starting : VoiceInputState + + data object Listening : VoiceInputState + + data object Processing : VoiceInputState + + data class Result( + val text: String, + ) : VoiceInputState + + data class Error( + val messageResId: Int, + val isRetryable: Boolean, + ) : VoiceInputState +} + +@Singleton +class VoiceInputManager + @Inject + constructor( + @ApplicationContext private val context: Context, + ) : Closeable { + private val handler = Handler(Looper.getMainLooper()) + private val mainDispatcher = provideMainDispatcher() + private val scope = CoroutineScope(mainDispatcher + SupervisorJob()) + private val mutex = Mutex() + + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val audioFocusListener = + AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS -> { + Timber.d("Permanent audio focus loss. Stopping listening.") + stopListening() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + Timber.d("Transient audio focus loss. Ignoring to allow SpeechRecognizer to work.") + } + + else -> { + Timber.d("Audio focus change: $focusChange") + } + } + } + + private val audioFocusRequest: AudioFocusRequest? by lazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioFocusRequest + .Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) + .setOnAudioFocusChangeListener(audioFocusListener, handler) + .build() + } else { + null + } + } + + @Suppress("DEPRECATION") + private fun requestAudioFocusCompat(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.requestAudioFocus(it) } + ?: AudioManager.AUDIOFOCUS_REQUEST_GRANTED + } else { + audioManager.requestAudioFocus( + audioFocusListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, + ) + } + + @Suppress("DEPRECATION") + private fun abandonAudioFocusCompat() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } else { + audioManager.abandonAudioFocus(audioFocusListener) + } + } + + private val _state = MutableStateFlow(VoiceInputState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _soundLevel = MutableStateFlow(0f) + val soundLevel: StateFlow = _soundLevel.asStateFlow() + + private val _partialResult = MutableStateFlow("") + val partialResult: StateFlow = _partialResult.asStateFlow() + + val isAvailable = SpeechRecognizer.isRecognitionAvailable(context) + val hasPermission: Boolean + get() = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + + private var recognizer: SpeechRecognizer? = null + private var timeoutJob: Job? = null + + private fun provideMainDispatcher(): CoroutineDispatcher = + try { + Dispatchers.Main.immediate + } catch (_: IllegalStateException) { + // Fallback for unit tests where Main dispatcher is not installed + handler.asCoroutineDispatcher() + } + + private val recognitionIntent by lazy { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true) + putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, MAX_RESULTS) + } + } + + private fun isNetworkAvailable(): Boolean { + val network = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun startListening() { + scope.launch { + mutex.withLock { + val currentState = _state.value + if (currentState is VoiceInputState.Starting || currentState is VoiceInputState.Listening) { + return@withLock + } + + val hadRecognizer = recognizer != null + if (hadRecognizer) { + destroyRecognizer() + } + + if (!isNetworkAvailable()) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_network, + isRetryable = true, + ) + } + return@withLock + } + + val focusResult = requestAudioFocusCompat() + if (focusResult != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_audio, + isRetryable = true, + ) + } + return@withLock + } + + cancelTimeout() + handler.post { + _partialResult.value = "" + _soundLevel.value = 0f + _state.value = VoiceInputState.Starting + } + + // Give the OS time to release the mic before recreating when replacing an old recognizer + if (hadRecognizer) { + delay(RECOGNIZER_RECREATE_DELAY_MS) + } + + val newRecognizer = SpeechRecognizer.createSpeechRecognizer(context) + recognizer = newRecognizer + newRecognizer.setRecognitionListener(createRecognitionListener(newRecognizer)) + + try { + newRecognizer.startListening(recognitionIntent) + } catch (e: Exception) { + Timber.e(e, "Failed to start speech recognition") + destroyRecognizer() + cancelTimeout() + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_start_failed, + isRetryable = true, + ) + } + } + } + } + } + + fun stopListening() { + scope.launch { + mutex.withLock { + cancelTimeout() + close() + } + } + } + + private fun cancelTimeout() { + timeoutJob?.cancel() + timeoutJob = null + } + + private fun startTimeout() { + cancelTimeout() + timeoutJob = + scope.launch { + delay(LISTENING_TIMEOUT_MS) + mutex.withLock { + if (_state.value is VoiceInputState.Listening && recognizer != null) { + val partial = _partialResult.value + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = + if (partial.isNotBlank()) { + VoiceInputState.Result(partial) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_timeout, + isRetryable = true, + ) + } + } + } + } + } + } + + fun acknowledge() { + handler.post { _state.value = VoiceInputState.Idle } + } + + fun onPermissionGranted() = startListening() + + fun onPermissionDenied() { + Timber.w("RECORD_AUDIO permission denied") + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = R.string.voice_error_permissions, + isRetryable = false, + ) + } + } + + private fun destroyRecognizer() { + abandonAudioFocusCompat() + // Null out FIRST to invalidate callbacks before cancel() can trigger them + val rec = recognizer + recognizer = null + rec?.let { + try { + it.cancel() + it.destroy() + } catch (e: Exception) { + Timber.w(e, "Error destroying speech recognizer") + } + } + } + + override fun close() { + destroyRecognizer() + handler.post { + _soundLevel.value = 0f + _partialResult.value = "" + _state.value = VoiceInputState.Idle + } + } + + private fun createRecognitionListener(activeRecognizer: SpeechRecognizer) = + object : RecognitionListener { + // Guard against callbacks from zombie recognizers + private fun isValid() = recognizer === activeRecognizer + + override fun onReadyForSpeech(params: Bundle?) { + if (!isValid()) return + handler.post { _state.value = VoiceInputState.Listening } + startTimeout() + } + + override fun onBeginningOfSpeech() { + if (!isValid()) return + } + + override fun onRmsChanged(rmsdB: Float) { + if (!isValid()) return + handler.post { _soundLevel.value = normalizeRmsDb(rmsdB) } + } + + override fun onBufferReceived(buffer: ByteArray?) = Unit + + override fun onEndOfSpeech() { + if (!isValid()) return + cancelTimeout() + handler.post { _state.value = VoiceInputState.Processing } + } + + override fun onError(error: Int) { + if (!isValid()) return + Timber.e("Voice recognition error code: $error") + cancelTimeout() + destroyRecognizer() + + if (error == SpeechRecognizer.ERROR_TOO_MANY_REQUESTS) { + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = false, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + return + } + handler.post { + _state.value = + VoiceInputState.Error( + messageResId = ERROR_TO_RESOURCE_MAP[error] ?: R.string.voice_error_unknown, + isRetryable = error in RETRYABLE_ERRORS, + ) + _soundLevel.value = 0f + _partialResult.value = "" + } + } + + override fun onResults(results: Bundle?) { + if (!isValid()) return + cancelTimeout() + val spokenText = + results + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + handler.post { + _state.value = + if (!spokenText.isNullOrBlank()) { + VoiceInputState.Result(spokenText) + } else { + VoiceInputState.Error( + messageResId = R.string.voice_error_no_match, + isRetryable = true, + ) + } + _soundLevel.value = 0f + } + } + + override fun onPartialResults(partialResults: Bundle?) { + if (!isValid()) return + partialResults + ?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION) + ?.firstOrNull() + ?.takeIf { it.isNotBlank() } + ?.let { handler.post { _partialResult.value = it } } + } + + override fun onEvent( + eventType: Int, + params: Bundle?, + ) = Unit + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt new file mode 100644 index 00000000..3042ac06 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/VoiceSearchButton.kt @@ -0,0 +1,414 @@ +package com.github.damontecres.wholphin.ui.components + +import android.Manifest +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSizeIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.OutlinedButton +import androidx.tv.material3.OutlinedButtonDefaults +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import kotlinx.coroutines.delay + +private const val ERROR_AUTO_DISMISS_DELAY_MS = 3000L +private const val SOUND_LEVEL_SCALE_FACTOR = 0.15f +private val BUBBLE_SIZE = 160.dp +private val MIC_ICON_FONT_SIZE = 56.sp +private val BUTTON_ICON_FONT_SIZE = 20.sp +private val CONTENT_SPACING = 48.dp +private val HORIZONTAL_PADDING = 64.dp +private val DISMISS_HINT_BOTTOM_PADDING = 32.dp +private const val HINT_TEXT_ALPHA = 0.5f +private const val SOUND_LEVEL_ANIM_MS = 100 +private const val BASE_PULSE_ANIM_MS = 800 +private const val RIPPLE_ANIM_MS = 1500 +private const val DOTS_ANIM_MS = 1200 +private const val RIPPLE_CANVAS_SCALE = 1.8f +private const val MAX_RIPPLE_EXPANSION = 0.35f +private val RIPPLE_STROKE_WIDTH = 2.dp +private const val RIPPLE_MAX_ALPHA = 0.4f + +private fun VoiceInputState.shouldShowOverlay() = + this is VoiceInputState.Starting || + this is VoiceInputState.Listening || + this is VoiceInputState.Processing || + this is VoiceInputState.Error + +@Composable +fun VoiceSearchButton( + onSpeechResult: (String) -> Unit, + voiceInputManager: VoiceInputManager?, + modifier: Modifier = Modifier, +) { + if (voiceInputManager == null || !voiceInputManager.isAvailable) return + + val state by voiceInputManager.state.collectAsState() + val soundLevel by voiceInputManager.soundLevel.collectAsState() + val partialResult by voiceInputManager.partialResult.collectAsState() + + LaunchedEffect(state) { + val currentState = state + when (currentState) { + is VoiceInputState.Result -> { + onSpeechResult(currentState.text) + // Small delay to allow focus to be restored before dialog dismisses + delay(50) + voiceInputManager.acknowledge() + } + + is VoiceInputState.Error -> { + if (!currentState.isRetryable) { + delay(ERROR_AUTO_DISMISS_DELAY_MS) + voiceInputManager.acknowledge() + } + } + + else -> {} + } + } + + val permissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { isGranted -> + if (isGranted) { + voiceInputManager.onPermissionGranted() + } else { + voiceInputManager.onPermissionDenied() + } + } + + if (state.shouldShowOverlay()) { + val errorState = state as? VoiceInputState.Error + val errorMessage = errorState?.messageResId?.let { stringResource(it) } + VoiceSearchOverlay( + soundLevel = soundLevel, + partialResult = partialResult, + isStarting = state is VoiceInputState.Starting, + isProcessing = state is VoiceInputState.Processing, + errorMessage = errorMessage, + isRetryable = errorState?.isRetryable == true, + onRetry = { voiceInputManager.startListening() }, + onDismiss = { voiceInputManager.stopListening() }, + ) + } + + Button( + onClick = { + when (state) { + is VoiceInputState.Starting, + is VoiceInputState.Listening, + -> { + voiceInputManager.stopListening() + } + + else -> { + if (voiceInputManager.hasPermission) { + voiceInputManager.startListening() + } else { + permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + }, + modifier = + modifier.requiredSizeIn( + minWidth = MinButtonSize, + minHeight = MinButtonSize, + maxWidth = MinButtonSize, + maxHeight = MinButtonSize, + ), + contentPadding = PaddingValues(0.dp), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = BUTTON_ICON_FONT_SIZE, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } +} + +@Composable +private fun VoiceRippleRings( + rippleProgress: Float, + bubbleSize: androidx.compose.ui.unit.Dp, + color: Color, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val rippleStroke = + remember(density) { + Stroke(width = with(density) { RIPPLE_STROKE_WIDTH.toPx() }) + } + + Canvas(modifier = modifier.size(bubbleSize * RIPPLE_CANVAS_SCALE)) { + val canvasCenter = center + val baseRadius = bubbleSize.toPx() / 2 + val maxExpansion = bubbleSize.toPx() * MAX_RIPPLE_EXPANSION + + for (i in 0..2) { + val ringProgress = (rippleProgress + (i * 0.33f)) % 1f + val ringRadius = baseRadius + (ringProgress * maxExpansion) + val ringAlpha = (1f - ringProgress) * RIPPLE_MAX_ALPHA + drawCircle( + color = color.copy(alpha = ringAlpha), + radius = ringRadius, + center = canvasCenter, + style = rippleStroke, + ) + } + } +} + +private fun getStatusText( + errorMessage: String?, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + startingText: String, + processingText: String, + listeningText: String, + dotCount: Int, +): Pair { + val dots = ".".repeat(dotCount) + return when { + errorMessage != null -> errorMessage to errorMessage + isProcessing -> (processingText + dots) to processingText + isStarting -> (startingText + dots) to startingText + partialResult.isNotBlank() -> partialResult to partialResult + else -> (listeningText + dots) to listeningText + } +} + +@Composable +private fun VoiceSearchOverlay( + soundLevel: Float, + partialResult: String, + isStarting: Boolean, + isProcessing: Boolean, + errorMessage: String?, + isRetryable: Boolean, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val primaryColor = MaterialTheme.colorScheme.primary + val onPrimaryColor = MaterialTheme.colorScheme.onPrimary + val errorColor = MaterialTheme.colorScheme.error + + val animatedSoundLevel by animateFloatAsState( + targetValue = soundLevel, + animationSpec = tween(durationMillis = SOUND_LEVEL_ANIM_MS), + label = "soundLevel", + ) + + val infiniteTransition = rememberInfiniteTransition(label = "pulse") + val basePulse by infiniteTransition.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = BASE_PULSE_ANIM_MS), + repeatMode = RepeatMode.Reverse, + ), + label = "basePulse", + ) + + // Only animate ripples when actively listening (not starting, processing, or in error) + val shouldAnimateRipples = !isStarting && !isProcessing && errorMessage == null + val rippleProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = if (shouldAnimateRipples) 1f else 0f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = RIPPLE_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "ripple", + ) + + val dotAnimation by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 4f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = DOTS_ANIM_MS), + repeatMode = RepeatMode.Restart, + ), + label = "dots", + ) + + val bubbleScale = basePulse + (animatedSoundLevel * SOUND_LEVEL_SCALE_FACTOR) + + val statusFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + statusFocusRequester.requestFocus() + } + + Dialog( + onDismissRequest = onDismiss, + properties = + DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false, + ), + ) { + Box( + modifier = + Modifier + .fillMaxWidth(0.85f) + .wrapContentHeight() + .padding(vertical = 48.dp) + .clip(MaterialTheme.shapes.large) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp)), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(CONTENT_SPACING), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = HORIZONTAL_PADDING), + ) { + Box(contentAlignment = Alignment.Center) { + val rippleAlpha = if (shouldAnimateRipples) 1f else 0f + Box(modifier = Modifier.graphicsLayer { alpha = rippleAlpha }) { + VoiceRippleRings( + rippleProgress = rippleProgress, + bubbleSize = BUBBLE_SIZE, + color = primaryColor, + ) + } + + Box( + modifier = + Modifier + .size(BUBBLE_SIZE) + .graphicsLayer { + scaleX = bubbleScale + scaleY = bubbleScale + }.clip(CircleShape) + .background(if (errorMessage != null) errorColor else primaryColor), + contentAlignment = Alignment.Center, + ) { + val voiceSearchDesc = stringResource(R.string.voice_search) + Text( + text = stringResource(R.string.fa_microphone), + fontFamily = FontAwesome, + fontSize = MIC_ICON_FONT_SIZE, + color = onPrimaryColor, + textAlign = TextAlign.Center, + modifier = Modifier.semantics { contentDescription = voiceSearchDesc }, + ) + } + } + + val startingText = stringResource(R.string.voice_starting) + val processingText = stringResource(R.string.processing) + val listeningText = stringResource(R.string.voice_search_prompt) + val (statusText, accessibilityDescription) = + getStatusText( + errorMessage = errorMessage, + partialResult = partialResult, + isStarting = isStarting, + isProcessing = isProcessing, + startingText = startingText, + processingText = processingText, + listeningText = listeningText, + dotCount = dotAnimation.toInt(), + ) + + Column( + modifier = Modifier.weight(1f).focusRequester(statusFocusRequester), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = statusText, + style = MaterialTheme.typography.headlineMedium, + color = if (errorMessage != null) errorColor else Color.White, + modifier = Modifier.semantics { contentDescription = accessibilityDescription }, + ) + + if (errorMessage != null && isRetryable) { + OutlinedButton( + onClick = onRetry, + modifier = Modifier.padding(top = 16.dp), + shape = ButtonDefaults.shape(RoundedCornerShape(50)), + colors = + OutlinedButtonDefaults.colors( + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = stringResource(R.string.retry), + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + + Text( + text = stringResource(R.string.press_back_to_cancel), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = HINT_TEXT_ALPHA), + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = DISMISS_HINT_BOTTOM_PADDING), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 4947fda6..fa32c595 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -19,17 +21,18 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.components.ScrollableDialog -import com.github.damontecres.wholphin.ui.components.formatAudioCodec -import com.github.damontecres.wholphin.ui.components.formatSubtitleCodec import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatAudioCodec +import com.github.damontecres.wholphin.ui.util.StreamFormatting.formatSubtitleCodec import com.github.damontecres.wholphin.util.languageName 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.VideoRange import org.jellyfin.sdk.model.api.VideoRangeType +import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale data class ItemDetailsDialogInfo( @@ -54,33 +57,32 @@ fun ItemDetailsDialog( val subtitleLabel = stringResource(R.string.subtitle) val bitrateLabel = stringResource(R.string.bitrate) val unknown = stringResource(R.string.unknown) + val runtimeLabel = stringResource(R.string.runtime_sort) ScrollableDialog( onDismissRequest = onDismissRequest, - width = 720.dp, - maxHeight = 480.dp, - itemSpacing = 4.dp, + width = 680.dp, + maxHeight = 440.dp, + itemSpacing = 8.dp, ) { item { - Text( - text = info.title, - style = MaterialTheme.typography.titleLarge, - ) - } - if (info.genres.isNotEmpty()) { - item { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Text( - text = info.genres.joinToString(", "), - style = MaterialTheme.typography.titleSmall, - ) - } - } - if (info.overview.isNotNullOrBlank()) { - item { - Text( - text = info.overview, - style = MaterialTheme.typography.bodyLarge, + text = info.title, + style = MaterialTheme.typography.headlineSmall, ) + if (info.genres.isNotEmpty()) { + Text( + text = info.genres.joinToString(", "), + style = MaterialTheme.typography.titleSmall, + ) + } + if (info.overview.isNotNullOrBlank()) { + Text( + text = info.overview, + style = MaterialTheme.typography.bodyMedium, + ) + } } } @@ -89,13 +91,19 @@ fun ItemDetailsDialog( source.mediaStreams?.letNotEmpty { mediaStreams -> item { Spacer(Modifier.height(8.dp)) + HorizontalDivider() } // General file information item { val containerLabel = stringResource(R.string.container) MediaInfoSection( - title = stringResource(R.string.general), + title = + titleIndex( + stringResource(R.string.general), + index, + info.files.size, + ), items = buildList { source.container?.let { add(containerLabel to it) } @@ -111,31 +119,38 @@ fun ItemDetailsDialog( bitrateLabel to formatBytes(it, byteRateSuffixes), ) } + source.runTimeTicks?.let { + add(runtimeLabel to it.ticks.toString()) + } }, ) } // Video streams - items(mediaStreams.filter { it.type == MediaStreamType.VIDEO }) { stream -> + val videoStreams = mediaStreams.filter { it.type == MediaStreamType.VIDEO } + itemsIndexed(videoStreams) { index, stream -> MediaInfoSection( - title = videoLabel, - items = buildVideoStreamInfo(context, stream), + title = titleIndex(videoLabel, index, videoStreams.size), + items = remember { buildVideoStreamInfo(context, stream) }, + additional = remember { buildVideoStreamInfoAdditional(context, stream) }, ) } // Audio streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.AUDIO } - .chunked(3), - ) { streamGroup -> + val audioStreams = mediaStreams.filter { it.type == MediaStreamType.AUDIO } + itemsIndexed(audioStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = audioLabel, + title = + titleIndex( + audioLabel, + groupIndex * 3 + index, + audioStreams.size, + ), items = buildAudioStreamInfo(context, stream), modifier = Modifier.weight(1f), ) @@ -148,19 +163,21 @@ fun ItemDetailsDialog( } // Subtitle streams - display multiple per row - items( - mediaStreams - .filter { it.type == MediaStreamType.SUBTITLE } - .chunked(3), - ) { streamGroup -> + val subtitleStreams = mediaStreams.filter { it.type == MediaStreamType.SUBTITLE } + itemsIndexed(subtitleStreams.chunked(3)) { groupIndex, streamGroup -> Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth(), ) { - streamGroup.forEach { stream -> + streamGroup.forEachIndexed { index, stream -> MediaInfoSection( - title = subtitleLabel, - items = buildSubtitleStreamInfo(context, stream), + title = + titleIndex( + subtitleLabel, + groupIndex * 3 + index, + subtitleStreams.size, + ), + items = buildSubtitleStreamInfo(context, stream, showFilePath), modifier = Modifier.weight(1f), ) } @@ -185,6 +202,7 @@ private fun MediaInfoSection( title: String, items: List>, modifier: Modifier = Modifier, + additional: List> = listOf(), ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -192,66 +210,86 @@ private fun MediaInfoSection( ) { Text( text = title, - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, ) - items.forEach { (label, value) -> - Row( - modifier = Modifier.padding(start = 12.dp), - ) { - Text( - text = "$label: ", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(start = 12.dp), + ) { + Column(modifier = Modifier.weight(1f, fill = false)) { + items.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + if (additional.isNotEmpty()) { + Column(modifier = Modifier.weight(1f, fill = false)) { + additional.forEach { (label, value) -> + Row( + modifier = Modifier, + ) { + Text( + text = "$label: ", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } } } } } +fun titleIndex( + title: String, + index: Int, + total: Int, +) = if (total > 1) { + "$title (${index + 1})" +} else { + title +} + private fun buildVideoStreamInfo( context: Context, stream: MediaStream, ): List> = buildList { - val titleLabel = context.getString(R.string.title) - val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) - val profileLabel = context.getString(R.string.profile) - val levelLabel = context.getString(R.string.level) - val resolutionLabel = context.getString(R.string.resolution) - val aspectRatioLabel = context.getString(R.string.aspect_ratio) - val anamorphicLabel = context.getString(R.string.anamorphic) - val interlacedLabel = context.getString(R.string.interlaced) - val framerateLabel = context.getString(R.string.framerate) - val bitrateLabel = context.getString(R.string.bitrate) - val bitDepthLabel = context.getString(R.string.bit_depth) - val videoRangeLabel = context.getString(R.string.video_range) - val videoRangeTypeLabel = context.getString(R.string.video_range_type) - val colorSpaceLabel = context.getString(R.string.color_space) - val colorTransferLabel = context.getString(R.string.color_transfer) - val colorPrimariesLabel = context.getString(R.string.color_primaries) - val pixelFormatLabel = context.getString(R.string.pixel_format) - val refFramesLabel = context.getString(R.string.ref_frames) - val nalLabel = context.getString(R.string.nal) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + + val titleLabel = context.getString(R.string.title) + val codecLabel = context.getString(R.string.codec) + val resolutionLabel = context.getString(R.string.resolution) + val aspectRatioLabel = context.getString(R.string.aspect_ratio) + val framerateLabel = context.getString(R.string.framerate) + val bitrateLabel = context.getString(R.string.bitrate) + val profileLabel = context.getString(R.string.profile) + val levelLabel = context.getString(R.string.level) + val interlacedLabel = context.getString(R.string.interlaced) + val videoRangeLabel = context.getString(R.string.video_range) val sdrStr = context.getString(R.string.sdr) val hdrStr = context.getString(R.string.hdr) - val hdr10Str = context.getString(R.string.hdr10) - val hdr10PlusStr = context.getString(R.string.hdr10_plus) - val hlgStr = context.getString(R.string.hlg) - val bitUnit = context.getString(R.string.bit_unit) stream.title?.let { add(titleLabel to it) } stream.codec?.let { add(codecLabel to it.uppercase()) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } - stream.profile?.let { add(profileLabel to it) } - stream.level?.let { add(levelLabel to it.toString()) } if (stream.width != null && stream.height != null) { add(resolutionLabel to "${stream.width}x${stream.height}") } @@ -259,23 +297,55 @@ private fun buildVideoStreamInfo( val aspectRatio = calculateAspectRatio(stream.width!!, stream.height!!) add(aspectRatioLabel to aspectRatio) } - stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } - stream.isInterlaced?.let { add(interlacedLabel to if (it) yesStr else noStr) } + stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.averageFrameRate?.let { add(framerateLabel to String.format(Locale.getDefault(), "%.3f", it)) } - stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } - stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } - stream.videoRange?.let { + + stream.videoRange.let { val rangeStr = when (it) { VideoRange.SDR -> sdrStr VideoRange.HDR -> hdrStr VideoRange.UNKNOWN -> null - else -> null } rangeStr?.let { add(videoRangeLabel to it) } } + stream.profile?.let { add(profileLabel to it) } + stream.level?.let { add(levelLabel to it.toString()) } + stream.isInterlaced.let { add(interlacedLabel to if (it) yesStr else noStr) } + } + +private fun buildVideoStreamInfoAdditional( + context: Context, + stream: MediaStream, +): List> = + buildList { + val yesStr = context.getString(R.string.yes) + val noStr = context.getString(R.string.no) + + val avcLabel = context.getString(R.string.avc) + val anamorphicLabel = context.getString(R.string.anamorphic) + val bitDepthLabel = context.getString(R.string.bit_depth) + + val videoRangeTypeLabel = context.getString(R.string.video_range_type) + val colorSpaceLabel = context.getString(R.string.color_space) + val colorTransferLabel = context.getString(R.string.color_transfer) + val colorPrimariesLabel = context.getString(R.string.color_primaries) + val pixelFormatLabel = context.getString(R.string.pixel_format) + val refFramesLabel = context.getString(R.string.ref_frames) + val nalLabel = context.getString(R.string.nal) + val dolbyVisionLabel = context.getString(R.string.dolby_vision) + + val sdrStr = context.getString(R.string.sdr) + val hdr10Str = context.getString(R.string.hdr10) + val hdr10PlusStr = context.getString(R.string.hdr10_plus) + val hlgStr = context.getString(R.string.hlg) + val bitUnit = context.getString(R.string.bit_unit) + + stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } + stream.isAnamorphic?.let { add(anamorphicLabel to if (it) yesStr else noStr) } + stream.bitDepth?.let { add(bitDepthLabel to "$it $bitUnit") } stream.videoRangeType?.let { val rangeTypeStr = when (it) { @@ -304,7 +374,8 @@ private fun buildVideoStreamInfo( stream.colorPrimaries?.let { add(colorPrimariesLabel to it) } stream.pixelFormat?.let { add(pixelFormatLabel to it) } stream.refFrames?.let { add(refFramesLabel to it.toString()) } - stream.nalLengthSize?.let { add(nalLabel to it.toString()) } + stream.nalLengthSize?.let { add(nalLabel to it) } + stream.videoDoViTitle?.let { add(dolbyVisionLabel to it) } } private fun buildAudioStreamInfo( @@ -320,7 +391,7 @@ private fun buildAudioStreamInfo( val bitrateLabel = context.getString(R.string.bitrate) val sampleRateLabel = context.getString(R.string.sample_rate) val defaultLabel = context.getString(R.string.default_track) - val externalLabel = context.getString(R.string.external_track) + val profileLabel = context.getString(R.string.profile) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) val sampleRateUnit = context.getString(R.string.sample_rate_unit) @@ -328,11 +399,12 @@ private fun buildAudioStreamInfo( stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatAudioCodec(context, it, stream.profile) ?: it.uppercase() + val formattedCodec = formatAudioCodec(context, it, stream.profile) + " ($it)" add(codecLabel to formattedCodec) } stream.channelLayout?.let { add(layoutLabel to it) } stream.channels?.let { add(channelsLabel to it.toString()) } + stream.profile?.let { add(profileLabel to it) } stream.bitRate?.let { add(bitrateLabel to formatBytes(it, byteRateSuffixes)) } stream.sampleRate?.let { add(sampleRateLabel to "$it $sampleRateUnit") } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } @@ -341,28 +413,34 @@ private fun buildAudioStreamInfo( private fun buildSubtitleStreamInfo( context: Context, stream: MediaStream, + showPath: Boolean, ): List> = buildList { val titleLabel = context.getString(R.string.title) val languageLabel = context.getString(R.string.language) val codecLabel = context.getString(R.string.codec) - val avcLabel = context.getString(R.string.avc) val defaultLabel = context.getString(R.string.default_track) val forcedLabel = context.getString(R.string.forced_track) val externalLabel = context.getString(R.string.external_track) val yesStr = context.getString(R.string.yes) val noStr = context.getString(R.string.no) + val pathLabel = context.getString(R.string.path) stream.title?.let { add(titleLabel to it) } stream.language?.let { add(languageLabel to languageName(it)) } stream.codec?.let { - val formattedCodec = formatSubtitleCodec(it) ?: it.uppercase() + val formattedCodec = formatSubtitleCodec(it) + " ($it)" add(codecLabel to formattedCodec) } - stream.isAvc?.let { add(avcLabel to if (it) yesStr else noStr) } stream.isDefault?.let { add(defaultLabel to if (it) yesStr else noStr) } stream.isForced?.let { add(forcedLabel to if (it) yesStr else noStr) } stream.isExternal?.let { add(externalLabel to if (it) yesStr else noStr) } + stream.isHearingImpaired?.let { + add((stream.localizedHearingImpaired ?: "SDH") to if (it) yesStr else noStr) + } + if (showPath) { + stream.path?.let { add(pathLabel to it) } + } } private fun calculateAspectRatio( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index ba74641a..f028ed1e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -73,12 +73,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -import java.util.UUID private const val DEBUG = false interface CardGridItem { - val id: UUID + val gridId: String val playable: Boolean val sortName: String } @@ -117,12 +116,16 @@ fun CardGrid( val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val fractionCacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 0.5f) - val gridState = rememberLazyGridState(cacheWindow = fractionCacheWindow) + var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } + val gridState = + rememberLazyGridState( + cacheWindow = fractionCacheWindow, + initialFirstVisibleItemIndex = focusedIndex, + ) val scope = rememberCoroutineScope() val firstFocus = remember { FocusRequester() } val zeroFocus = remember { FocusRequester() } var previouslyFocusedIndex by rememberSaveable { mutableIntStateOf(0) } - var focusedIndex by rememberSaveable { mutableIntStateOf(initialPosition) } var alphabetFocus by remember { mutableStateOf(false) } val focusOn = { index: Int -> @@ -192,210 +195,224 @@ fun CardGrid( } } - var longPressing by remember { mutableStateOf(false) } - Row( -// horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = - modifier - .fillMaxSize() - .onKeyEvent { - if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") - if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { - longPressing = true - val newPosition = previouslyFocusedIndex - if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") - if (newPosition > 0) { - focusOn(newPosition) - scope.launch(ExceptionHandler()) { - gridState.scrollToItem(newPosition, -columns) - firstFocus.tryRequestFocus() - } - } - return@onKeyEvent true - } else if (it.type == KeyEventType.KeyUp) { - if (longPressing && it.key == Key.Back) { - longPressing = false - return@onKeyEvent true - } - longPressing = false - } - if (it.type != KeyEventType.KeyUp) { - return@onKeyEvent false - } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { - jumpToTop() - return@onKeyEvent true - } else if (isPlayKeyUp(it)) { - val item = pager.getOrNull(focusedIndex) - if (item?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(focusedIndex, item) - } - return@onKeyEvent true - } else if (useJumpRemoteButtons && isForwardButton(it)) { - jump(jump1) - return@onKeyEvent true - } else if (useJumpRemoteButtons && isBackwardButton(it)) { - jump(-jump1) - return@onKeyEvent true - } else { - return@onKeyEvent false - } - }, - ) { - if (showJumpButtons && pager.isNotEmpty()) { - JumpButtons( - jump1 = jump1, - jump2 = jump2, - jumpClick = { jump(it) }, - modifier = Modifier.align(Alignment.CenterVertically), + if (pager.isEmpty()) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.fillMaxSize(), + ) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, ) } - Box( - modifier = Modifier.weight(1f), - ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position -// focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition + } else { + var longPressing by remember { mutableStateOf(false) } + Row( +// horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxSize() + .onKeyEvent { + if (DEBUG) Timber.d("onKeyEvent: ${it.nativeKeyEvent}") + if (useBackToJump && it.key == Key.Back && it.nativeKeyEvent.isLongPress) { + longPressing = true + val newPosition = previouslyFocusedIndex + if (DEBUG) Timber.d("Back long pressed: newPosition=$newPosition") + if (newPosition > 0) { + focusOn(newPosition) + scope.launch(ExceptionHandler()) { + gridState.scrollToItem(newPosition, -columns) + firstFocus.tryRequestFocus() } } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier + return@onKeyEvent true + } else if (it.type == KeyEventType.KeyUp) { + if (longPressing && it.key == Key.Back) { + longPressing = false + return@onKeyEvent true + } + longPressing = false } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) + if (it.type != KeyEventType.KeyUp) { + return@onKeyEvent false + } else if (useBackToJump && it.key == Key.Back && focusedIndex > 0) { + jumpToTop() + return@onKeyEvent true + } else if (isPlayKeyUp(it)) { + val item = pager.getOrNull(focusedIndex) + if (item?.playable == true) { + Timber.v("Clicked play on ${item.gridId}") + onClickPlay.invoke(focusedIndex, item) } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isForwardButton(it)) { + jump(jump1) + return@onKeyEvent true + } else if (useJumpRemoteButtons && isBackwardButton(it)) { + jump(-jump1) + return@onKeyEvent true + } else { + return@onKeyEvent false + } + }, + ) { + if (showJumpButtons && pager.isNotEmpty()) { + JumpButtons( + jump1 = jump1, + jump2 = jump2, + jumpClick = { jump(it) }, + modifier = Modifier.align(Alignment.CenterVertically), + ) + } + Box( + modifier = Modifier.weight(1f), + ) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position +// focusedIndex = -1 } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } + } + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } - } - val context = LocalContext.current - val letters = context.getString(R.string.jump_letters) - // Letters - val currentLetter = - remember(focusedIndex) { - pager - .getOrNull(focusedIndex) - ?.sortName - ?.first() - ?.uppercaseChar() - ?.let { - if (it >= '0' && it <= '9') { - '#' - } else if (it >= 'A' && it <= 'Z') { - it - } else { - null - } - } - ?: letters[0] - } - if (showLetterButtons && pager.isNotEmpty()) { - AlphabetButtons( - letters = letters, - currentLetter = currentLetter, - modifier = - Modifier - .align(Alignment.CenterVertically) - .padding(end = 16.dp), - // Add end padding to push away from edge - letterClicked = { letter -> - scope.launch(ExceptionHandler()) { - val jumpPosition = - withContext(Dispatchers.IO) { - letterPosition.invoke(letter) + val context = LocalContext.current + val letters = context.getString(R.string.jump_letters) + // Letters + val currentLetter = + remember(focusedIndex) { + pager + .getOrNull(focusedIndex) + ?.sortName + ?.firstOrNull() + ?.uppercaseChar() + ?.let { + if (it >= '0' && it <= '9') { + '#' + } else if (it >= 'A' && it <= 'Z') { + it + } else { + null } - Timber.d("Alphabet jump to $jumpPosition") - if (jumpPosition >= 0) { - pager.getOrNull(jumpPosition) - gridState.scrollToItem(jumpPosition) - focusOn(jumpPosition) - alphabetFocus = true } - } - }, - ) + ?: letters[0] + } + if (showLetterButtons && pager.isNotEmpty()) { + AlphabetButtons( + letters = letters, + currentLetter = currentLetter, + modifier = + Modifier + .align(Alignment.CenterVertically) + .padding(end = 16.dp), + // Add end padding to push away from edge + letterClicked = { letter -> + scope.launch(ExceptionHandler()) { + val jumpPosition = + withContext(Dispatchers.IO) { + letterPosition.invoke(letter) + } + Timber.d("Alphabet jump to $jumpPosition") + if (jumpPosition >= 0) { + pager.getOrNull(jumpPosition) + gridState.scrollToItem(jumpPosition) + focusOn(jumpPosition) + alphabetFocus = true + } + } + }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 4af65f1d..91cd66f7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -25,7 +24,6 @@ import java.util.UUID fun CollectionFolderBoxSet( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index c71e0abe..6d1fdd02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -93,14 +93,19 @@ fun CollectionFolderLiveTv( remember { viewModel.getRememberedTab(preferences, destination.itemId, 0) } val folders by viewModel.recordingFolders.observeAsState(listOf()) + val tvGuideStr = stringResource(R.string.tv_guide) + val tvDvrStr = stringResource(R.string.tv_dvr_schedule) val tabs = - listOf( - TabId(stringResource(R.string.tv_guide), UUID.randomUUID()), - TabId(stringResource(R.string.tv_dvr_schedule), UUID.randomUUID()), - ) + folders + remember(folders) { + listOf( + TabId(tvGuideStr, UUID.randomUUID()), + TabId(tvDvrStr, UUID.randomUUID()), + ) + folders + } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember(tabs.size) { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -133,6 +138,7 @@ fun CollectionFolderLiveTv( .focusRequester(firstTabFocusRequester), tabs = tabs.map { it.title }, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -150,10 +156,12 @@ fun CollectionFolderLiveTv( 1 -> { DvrSchedule( - true, - Modifier - .fillMaxSize() - .focusRequester(focusRequester), + requestFocusAfterLoading = true, + focusRequesterOnEmpty = tabFocusRequesters[1], + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), ) } @@ -176,6 +184,7 @@ fun CollectionFolderLiveTv( }, playEnabled = false, defaultViewOptions = ViewOptions(), + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } else { ErrorMessage("Invalid tab index $selectedTabIndex", null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index f824a4b9..c47aeb23 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -37,7 +37,6 @@ import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel -import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.BaseItemKind @Composable @@ -59,9 +58,10 @@ fun CollectionFolderMovie( ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } +// LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } LaunchedEffect(selectedTabIndex) { logTab("movie", selectedTabIndex) @@ -71,7 +71,6 @@ fun CollectionFolderMovie( var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( modifier = modifier, ) { @@ -88,6 +87,7 @@ fun CollectionFolderMovie( .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -115,6 +115,7 @@ fun CollectionFolderMovie( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, + viewModelKey = "${destination.itemId}_library", initialFilter = CollectionFolderFilter( filter = @@ -135,6 +136,7 @@ fun CollectionFolderMovie( showHeader = position < columns }, playEnabled = true, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -146,6 +148,7 @@ fun CollectionFolderMovie( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, itemId = destination.itemId, + viewModelKey = "${destination.itemId}_collection", initialFilter = CollectionFolderFilter( filter = @@ -166,6 +169,7 @@ fun CollectionFolderMovie( showHeader = position < columns }, playEnabled = false, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -173,6 +177,7 @@ fun CollectionFolderMovie( 3 -> { GenreCardGrid( itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.MOVIE), modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 47547219..66ace2d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -9,7 +9,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid @@ -22,7 +21,6 @@ import java.util.UUID fun CollectionFolderPlaylist( preferences: UserPreferences, itemId: UUID, - item: BaseItem?, recursive: Boolean, modifier: Modifier = Modifier, filter: CollectionFolderFilter = CollectionFolderFilter(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index bf032813..c9fd5381 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -58,6 +58,7 @@ fun CollectionFolderTv( ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } val firstTabFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } @@ -74,7 +75,6 @@ fun CollectionFolderTv( var showHeader by rememberSaveable { mutableStateOf(true) } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Column( modifier = modifier, ) { @@ -91,6 +91,7 @@ fun CollectionFolderTv( .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } when (selectedTabIndex) { @@ -138,6 +139,7 @@ fun CollectionFolderTv( preferencesViewModel.navigationManager.navigateTo(item.destination()) }, playEnabled = false, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -145,6 +147,7 @@ fun CollectionFolderTv( 2 -> { GenreCardGrid( itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.SERIES), modifier = Modifier .padding(start = 16.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 60ad839e..f0ad2c0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -254,7 +254,7 @@ fun DebugPage( "Manufacturer: ${Build.MANUFACTURER}", "Model: ${Build.MODEL}", "Display Modes:", - *viewModel.refreshRateService.displayModes, + *viewModel.refreshRateService.supportedDisplayModes, ).forEach { Text( text = it.toString(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index c920fb5b..8a210c27 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -29,6 +30,12 @@ data class MoreDialogActions( var onClickAddPlaylist: (UUID) -> Unit, ) +enum class ClearChosenStreams { + NONE, + ITEM_AND_SERIES, + SERIES, +} + /** * Build the [DialogItem]s when clicking "More" * @@ -53,10 +60,12 @@ fun buildMoreDialogItems( sourceId: UUID?, watched: Boolean, favorite: Boolean, + canClearChosenStreams: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, onShowOverview: () -> Unit, + onClearChosenStreams: () -> Unit, ): List = buildList { add( @@ -69,7 +78,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, ), ) }, @@ -157,6 +165,7 @@ fun buildMoreDialogItems( Destination.MediaItem( seriesId, BaseItemKind.SERIES, + null, ), ) }, @@ -172,6 +181,16 @@ fun buildMoreDialogItems( }, ) } + if (canClearChosenStreams) { + add( + DialogItem( + context.getString(R.string.clear_track_choices), + Icons.Default.Delete, + ) { + onClearChosenStreams() + }, + ) + } add( DialogItem( context.getString(R.string.play_with_transcoding), @@ -181,7 +200,6 @@ fun buildMoreDialogItems( Destination.Playback( item.id, item.resumeMs ?: 0L, - item, forceTranscoding = true, ), ) @@ -290,6 +308,7 @@ fun buildMoreDialogItemsForHome( Destination.MediaItem( it, BaseItemKind.SERIES, + null, ), ) }, @@ -309,7 +328,7 @@ fun buildMoreDialogItemsForPerson( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON)) + actions.navigateTo(Destination.MediaItem(itemId, BaseItemKind.PERSON, null)) }, ) add( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index a62c58f5..fc337d88 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -73,6 +73,7 @@ fun FavoritesPage( stringResource(R.string.playlists), stringResource(R.string.people), ) + val tabFocusRequesters = remember { List(tabs.size) { FocusRequester() } } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -107,6 +108,7 @@ fun FavoritesPage( .padding(start = 32.dp, top = 16.dp, bottom = 16.dp), tabs = tabs, onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, ) } // TODO playEnabled = true for movies & episodes @@ -139,6 +141,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -170,6 +173,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -202,6 +206,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -233,6 +238,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -264,6 +270,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = DefaultForFavoritesFilterOptions, + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } @@ -300,6 +307,7 @@ fun FavoritesPage( }, playEnabled = false, filterOptions = listOf(), + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt index d917b47a..65c9a0e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/ItemViewModel.kt @@ -27,7 +27,7 @@ abstract class ItemViewModel( ) : ViewModel() { val item = MutableLiveData(null) lateinit var itemId: String - lateinit var itemUuid: UUID + var itemUuid: UUID? = null suspend fun fetchItem(itemId: UUID): BaseItem = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 8af7ccab..17535577 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -40,13 +41,17 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.components.LoadingPage @@ -54,7 +59,11 @@ import com.github.damontecres.wholphin.ui.components.LoadingRow import com.github.damontecres.wholphin.ui.components.OverviewText import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination @@ -63,11 +72,14 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RowLoadingState import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType @@ -86,10 +98,12 @@ class PersonViewModel api: ApiClient, val navigationManager: NavigationManager, private val favoriteWatchManager: FavoriteWatchManager, + private val seerrService: SeerrService, ) : LoadingItemViewModel(api) { val movies = MutableLiveData(RowLoadingState.Pending) val series = MutableLiveData(RowLoadingState.Pending) val episodes = MutableLiveData(RowLoadingState.Pending) + val discovered = MutableStateFlow>(listOf()) fun init(itemId: UUID) { viewModelScope.launchIO( @@ -115,6 +129,10 @@ class PersonViewModel } else { episodes.setValueOnMain(RowLoadingState.Success(listOf())) } + viewModelScope.launchIO { + val results = seerrService.similar(person).orEmpty() + discovered.update { results } + } } } } @@ -155,8 +173,10 @@ class PersonViewModel fun setFavorite(favorite: Boolean) { viewModelScope.launchIO { - favoriteWatchManager.setFavorite(itemUuid, favorite) - fetchAndSetItem(itemUuid) + itemUuid?.let { + favoriteWatchManager.setFavorite(it, favorite) + fetchAndSetItem(it) + } } } } @@ -175,6 +195,7 @@ fun PersonPage( val movies by viewModel.movies.observeAsState(RowLoadingState.Pending) val series by viewModel.series.observeAsState(RowLoadingState.Pending) val episodes by viewModel.episodes.observeAsState(RowLoadingState.Pending) + val discovered by viewModel.discovered.collectAsState() val loading by viewModel.loading.observeAsState(LoadingState.Loading) when (val state = loading) { @@ -213,6 +234,10 @@ fun PersonPage( favoriteOnClick = { viewModel.setFavorite(!person.favorite) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigationManager.navigateTo(item.destination) + }, modifier = modifier, ) AnimatedVisibility(showOverviewDialog) { @@ -237,6 +262,7 @@ private const val HEADER_ROW = 0 private const val MOVIE_ROW = 1 private const val SERIES_ROW = 2 private const val EPISODE_ROW = 3 +private const val DISCOVER_ROW = EPISODE_ROW + 1 @Composable fun PersonPageContent( @@ -251,9 +277,11 @@ fun PersonPageContent( movies: RowLoadingState, series: RowLoadingState, episodes: RowLoadingState, + discovered: List, onClickItem: (Int, BaseItem) -> Unit, overviewOnClick: () -> Unit, favoriteOnClick: () -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } @@ -332,10 +360,45 @@ fun PersonPageContent( onClickItem = onClickItem, onClickPosition = { position = it }, showIfEmpty = false, - horizontalPadding = 32.dp, + horizontalPadding = 24.dp, modifier = Modifier.fillMaxWidth(), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + position = RowColumn(EPISODE_ROW, index) + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.heightEpisode, + modifier = + mod + .ifElse( + position.row == EPISODE_ROW && position.column == index, + Modifier.focusRequester(focusRequester), + ), + ) + }, ) } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = RowColumn(DISCOVER_ROW, index) + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequester, + ) + } + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 150228a5..5bf996a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -410,7 +410,7 @@ fun PlaylistItem( }, trailingContent = { item?.data?.runTimeTicks?.ticks?.roundMinutes?.let { duration -> - val now = LocalClock.current.now + val now by LocalClock.current.now val endTimeStr = remember(item, now) { val endTime = now.toLocalTime().plusSeconds(duration.inWholeSeconds) @@ -444,6 +444,7 @@ fun PlaylistItem( watched = item?.data?.userData?.played ?: false, unwatchedCount = item?.data?.userData?.unplayedItemCount ?: -1, watchedPercent = 0.0, + numberOfVersions = item?.data?.mediaSourceCount ?: 0, modifier = Modifier.width(160.dp), useFallbackText = false, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt new file mode 100644 index 00000000..9ddf4bc3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -0,0 +1,471 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.MoreDialogActions +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull + +@Composable +fun DiscoverMovieDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverMovieViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + viewModel.init() + onPauseOrDispose { } + } + val item by viewModel.movie.observeAsState() + val rating by viewModel.rating.observeAsState(null) + val people by viewModel.people.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.similar.observeAsState(listOf()) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + val userConfig by viewModel.userConfig.collectAsState(null) + val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val canCancel by viewModel.canCancelRequest.collectAsState() + + var overviewDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> }, + onClickFavorite = { itemId, favorite -> }, + onClickAddPlaylist = { itemId -> }, + ) + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { movie -> + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + userConfig = userConfig, + rating = rating, + canCancel = canCancel, + people = people, + trailers = trailers, + similar = similar, + recommended = recommended, + requestOnClick = { + movie.id?.let { id -> + if (request4kEnabled) { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = + listOf( + DialogItem( + text = requestStr, + onClick = { viewModel.request(id, false) }, + ), + DialogItem( + text = request4kStr, + onClick = { viewModel.request(id, true) }, + ), + ), + ) + } else { + viewModel.request(id, false) + } + } + }, + cancelOnClick = { + movie.id?.let { viewModel.cancelRequest(it) } + }, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: context.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + goToOnClick = { + movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = listOf(), + ) + }, + onLongClickPerson = { index, person -> }, + onLongClickSimilar = { index, similar -> + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + modifier = modifier, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +private const val HEADER_ROW = 0 +private const val PEOPLE_ROW = HEADER_ROW + 1 +private const val CHAPTER_ROW = PEOPLE_ROW + 1 +private const val EXTRAS_ROW = CHAPTER_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverMovieDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + movie: MovieDetails, + rating: DiscoverRating?, + canCancel: Boolean, + people: List, + trailers: List, + similar: List, + recommended: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var position by rememberInt(0) + val focusRequesters = remember { List(RECOMMENDED_ROW + 1) { FocusRequester() } } + + val bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Box(modifier = modifier) { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverMovieDetailsHeader( + preferences = preferences, + movie = movie, + rating = rating, + bringIntoViewRequester = bringIntoViewRequester, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(movie.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (people.isNotEmpty()) { + item { + DiscoverPersonRow( + people = people, + onClick = { + position = PEOPLE_ROW + onClickPerson.invoke(it) + }, + onLongClick = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), + ) + } + } + + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } +} + +@Composable +fun TrailerRow( + trailers: List, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> { + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + } + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + numberOfVersions = -1, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt new file mode 100644 index 00000000..4da6f938 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -0,0 +1,151 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import java.util.Locale +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverMovieDetailsHeader( + preferences: UserPreferences, + movie: MovieDetails, + rating: DiscoverRating?, + bringIntoViewRequester: BringIntoViewRequester, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // Title + Text( + text = movie.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(movie) { + buildList { + movie.releaseDate?.let(::add) + movie.runtime + ?.toDouble() + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + val release = + movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.getDefault().country } + ?: movie.releases + ?.results + ?.firstOrNull { it.iso31661 == Locale.US.country } + ?: movie.releases + ?.results + ?.firstOrNull() + + release + ?.releaseDates + ?.firstOrNull() + ?.certification + ?.let(::add) + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + } + } + + QuickDetails(details, null) + movie.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + + movie.tagline?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier, + ) + } + + // Description + movie.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + + val directorName = + remember(movie.credits?.crew) { + movie.credits + ?.crew + ?.filter { it.job == "Directing" } + ?.joinToString(", ") { it.name!! } + } + + directorName + ?.let { + Text( + text = stringResource(R.string.directed_by, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt new file mode 100644 index 00000000..0883023d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -0,0 +1,203 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverMovieViewModel.Factory::class) +class DiscoverMovieViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverMovieViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val movie = MutableLiveData(null) + val rating = MutableLiveData(null) + + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + val canCancelRequest = MutableStateFlow(false) + + val userConfig = seerrServerRepository.current.map { it?.config } + val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + this@DiscoverMovieViewModel.movie.setValueOnMain(movie) + movie + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(movie) + backdropService.submit(discoveredItem) + + updateCanCancel() + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + movie.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + movie.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverMovieViewModel.people.setValueOnMain(people) + val trailers = + movie.relatedVideos + ?.filter { it.type == RelatedVideo.Type.TRAILER } + ?.filter { it.name.isNotNullOrBlank() && it.url.isNotNullOrBlank() } + ?.map { + RemoteTrailer(it.name!!, it.url!!, null) + }.orEmpty() + this@DiscoverMovieViewModel.trailers.setValueOnMain(trailers) + } + + private suspend fun updateCanCancel() { + val user = userConfig.firstOrNull() + val canCancel = canUserCancelRequest(user, movie.value?.mediaInfo?.requests) + canCancelRequest.update { canCancel } + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request( + id: Int, + is4k: Boolean, + ) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = is4k, + mediaId = id, + mediaType = RequestPostRequest.MediaType.MOVIE, + ), + ) + fetchAndSetItem().await() + updateCanCancel() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + movie.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + updateCanCancel() + } + } + } + } + +fun canUserCancelRequest( + user: SeerrUserConfig?, + requests: List?, +) = (user.hasPermission(SeerrPermission.MANAGE_REQUESTS) && requests?.isNotEmpty() == true) || + ( + // User requested this + user.hasPermission(SeerrPermission.REQUEST) && + requests?.any { it.requestedBy?.id == user?.id } == true + ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt new file mode 100644 index 00000000..32d38aa5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -0,0 +1,161 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +@HiltViewModel(assistedFactory = DiscoverPersonViewModel.Factory::class) +class DiscoverPersonViewModel + @AssistedInject + constructor( + val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val backdropService: BackdropService, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverPersonViewModel + } + + val credits = MutableStateFlow>>(DataLoadingState.Pending) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + + val credits = + seerrService.api.personApi + .personPersonIdCombinedCreditsGet(personId = item.id) + .let { credits -> + val cast = + credits.cast + ?.map(::DiscoverItem) + .orEmpty() + val crew = + credits.crew + ?.map(::DiscoverItem) + .orEmpty() + cast + crew + } + this@DiscoverPersonViewModel.credits.update { + DataLoadingState.Success(credits) + } + } + } + } + +@Composable +fun DiscoverPersonPage( + person: DiscoverItem, + modifier: Modifier = Modifier, + viewModel: DiscoverPersonViewModel = + hiltViewModel( + creationCallback = { it.create(person) }, + ), +) { + val credits by viewModel.credits.collectAsState() + + when (val state = credits) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } + } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.discover) + (person.title?.let { ": $it" } ?: ""), + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: DiscoverItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, + onLongClickItem = { index: Int, item: DiscoverItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt new file mode 100644 index 00000000..2cf93e8b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -0,0 +1,551 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.SeerrPermission +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.hasPermission +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.DiscoverPersonRow +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.listToDotString +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverSeriesDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverSeriesViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + val item by viewModel.tvSeries.observeAsState() + val seasons by viewModel.seasons.observeAsState(listOf()) + val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) + val userConfig by viewModel.userConfig.collectAsState(null) + val request4kEnabled by viewModel.request4kEnabled.collectAsState(false) + val canCancel by viewModel.canCancelRequest.collectAsState() + + var overviewDialog by remember { mutableStateOf(null) } + var seasonDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val requestStr = stringResource(R.string.request) + val request4kStr = stringResource(R.string.request_4k) + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { item -> + val rating by viewModel.rating.observeAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + userConfig = userConfig, + rating = rating, + canCancel = canCancel, + seasons = seasons, + people = people, + similar = similar, + recommended = recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + }, + goToOnClick = { + item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: context.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = listOf(), + requestOnClick = { + item.id?.let { id -> + if (request4kEnabled) { + moreDialog = + DialogParams( + fromLongClick = false, + title = item.name ?: "", + items = + listOf( + DialogItem( + text = requestStr, + onClick = { viewModel.request(id, false) }, + ), + DialogItem( + text = request4kStr, + onClick = { viewModel.request(id, true) }, + ), + ), + ) + } else { + viewModel.request(id, false) + } + } + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +private const val HEADER_ROW = 0 +private const val SEASONS_ROW = HEADER_ROW + 1 +private const val PEOPLE_ROW = SEASONS_ROW + 1 +private const val EXTRAS_ROW = PEOPLE_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverSeriesDetailsContent( + preferences: UserPreferences, + userConfig: SeerrUserConfig?, + series: TvDetails, + rating: DiscoverRating?, + canCancel: Boolean, + seasons: List, + similar: List, + recommended: List, + trailers: List, + people: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (DiscoverItem) -> Unit, + onLongClickPerson: (Int, DiscoverItem) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + var position by rememberInt() + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val playFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + var moreDialog by remember { mutableStateOf(null) } + + Box( + modifier = modifier, + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier, + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverSeriesDetailsHeader( + series = series, + rating = rating, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(series.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + canRequest = userConfig.hasPermission(SeerrPermission.REQUEST), + canCancel = canCancel, + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } +// item { +// ItemRow( +// title = stringResource(R.string.tv_seasons) + " (${seasons.size})", +// items = seasons, +// onClickItem = { index, item -> +// position = SEASONS_ROW +// // onClickItem.invoke(index, item) +// }, +// onLongClickItem = { index, item -> +// position = SEASONS_ROW +// }, +// modifier = +// Modifier +// .fillMaxWidth() +// .focusRequester(focusRequesters[SEASONS_ROW]), +// cardContent = @Composable { index, item, mod, onClick, onLongClick -> +// SeasonCard( +// item = item, +// onClick = onClick, +// onLongClick = onLongClick, +// imageHeight = Cards.height2x3, +// imageWidth = Dp.Unspecified, +// showImageOverlay = true, +// modifier = mod, +// ) +// }, +// ) +// } + if (people.isNotEmpty()) { + item { + DiscoverPersonRow( + people = people, + onClick = { + position = PEOPLE_ROW + onClickPerson.invoke(it) + }, + onLongClick = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(index, person) + }, + modifier = Modifier.focusRequester(focusRequesters[PEOPLE_ROW]), + ) + } + } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +@Composable +fun DiscoverSeriesDetailsHeader( + series: TvDetails, + rating: DiscoverRating?, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = series.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(series) { + buildList { + series.firstAirDate?.let(::add) + series.episodeRunTime + ?.average() + ?.takeIf { !it.isNaN() && it > 0 } + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + // TODO + }.let { + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + } + } + + QuickDetails(details, null) + series.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + series.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) + } + } + } +} + +fun buildDialogForSeason( + context: Context, + s: BaseItem, + onClickItem: (BaseItem) -> Unit, + markPlayed: (Boolean) -> Unit, + onClickPlay: (Boolean) -> Unit, +): DialogParams { + val items = + buildList { + add( + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + onClickItem.invoke(s) + }, + ) + if (s.data.userData?.played == true) { + add( + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + markPlayed.invoke(false) + }, + ) + } else { + add( + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + markPlayed.invoke(true) + }, + ) + } + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlay.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlay.invoke(true) + }, + ) + } + return DialogParams( + title = s.name ?: context.getString(R.string.tv_season), + fromLongClick = true, + items = items, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt new file mode 100644 index 00000000..4803aee4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -0,0 +1,181 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient + +@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) +class DiscoverSeriesViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + private val seerrServerRepository: SeerrServerRepository, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverSeriesViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val tvSeries = MutableLiveData(null) + val rating = MutableLiveData(null) + + val seasons = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + val canCancelRequest = MutableStateFlow(false) + + val userConfig = seerrServerRepository.current.map { it?.config } + val request4kEnabled = seerrServerRepository.current.map { it?.request4kTvEnabled ?: false } + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) + tv + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(tv) + backdropService.submit(discoveredItem) + + updateCanCancel() + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + tv.credits + ?.cast + ?.map(::DiscoverItem) + .orEmpty() + + tv.credits + ?.crew + ?.map(::DiscoverItem) + .orEmpty() + this@DiscoverSeriesViewModel.people.setValueOnMain(people) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + private suspend fun updateCanCancel() { + val user = userConfig.firstOrNull() + val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) + canCancelRequest.update { canCancel } + } + + fun request( + id: Int, + is4k: Boolean, + ) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = is4k, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons + ), + ) + fetchAndSetItem().await() + updateCanCancel() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + updateCanCancel() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt new file mode 100644 index 00000000..4b73a0b2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -0,0 +1,154 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.components.TrailerButton +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlin.time.Duration + +@Composable +fun ExpandableDiscoverButtons( + canRequest: Boolean, + canCancel: Boolean, + availability: SeerrAvailability, + trailers: List?, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + buttonOnFocusChanged: (FocusState) -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + val text = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.request + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.pending + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.go_to + + SeerrAvailability.DELETED -> R.string.delete // TODO + } + val icon = + when (availability) { + SeerrAvailability.UNKNOWN -> R.string.fa_download + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> R.string.fa_clock + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> R.string.fa_play + + SeerrAvailability.DELETED -> R.string.fa_video // TODO + } + item("first") { + ExpandableFaButton( + title = text, + iconStringRes = icon, + enabled = if (availability == SeerrAvailability.UNKNOWN) canRequest else true, + onClick = { + when (availability) { + SeerrAvailability.UNKNOWN -> { + requestOnClick.invoke() + } + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + // TODO? + } + + SeerrAvailability.PARTIALLY_AVAILABLE, + SeerrAvailability.AVAILABLE, + -> { + goToOnClick.invoke() + } + + SeerrAvailability.DELETED -> { + // TODO + } + } + }, + modifier = + Modifier + .focusRequester(firstFocus) + .onFocusChanged(buttonOnFocusChanged), + ) + } + + if (canCancel) { + item("cancel") { + ExpandablePlayButton( + title = R.string.cancel, + icon = Icons.Default.Delete, + onClick = { + firstFocus.tryRequestFocus() + cancelOnClick.invoke() + }, + resume = Duration.ZERO, + enabled = canCancel, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } + + if (trailers != null) { + item("trailers") { + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = Modifier.onFocusChanged(buttonOnFocusChanged), + ) + } + } + + // More button + // No functionality yet +// item("more") { +// ExpandablePlayButton( +// R.string.more, +// Duration.ZERO, +// Icons.Default.MoreVert, +// { moreOnClick.invoke() }, +// Modifier +// .onFocusChanged(buttonOnFocusChanged), +// ) +// } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index bca08e7b..c46fe0cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -26,11 +25,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect -import androidx.lifecycle.compose.LifecycleStartEffect import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -48,10 +47,10 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID @@ -113,12 +112,12 @@ fun EpisodeDetails( LoadingState.Success -> { item?.let { ep -> - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } @@ -131,7 +130,6 @@ fun EpisodeDetails( Destination.Playback( ep.id, it.inWholeMilliseconds, - ep, ), ) }, @@ -157,6 +155,7 @@ fun EpisodeDetails( favorite = ep.data.userData?.isFavorite ?: false, seriesId = ep.data.seriesId, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = moreActions, onChooseVersion = { chooseVersion = @@ -182,6 +181,12 @@ fun EpisodeDetails( chooseStream( context = context, streams = source.mediaStreams.orEmpty(), + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, type = type, onClick = { trackIndex -> viewModel.saveTrackSelection( @@ -206,6 +211,9 @@ fun EpisodeDetails( ) } }, + onClearChosenStreams = { + viewModel.clearChosenStreams(chosenStreams) + }, ), ) }, @@ -291,9 +299,7 @@ fun EpisodeDetailsContent( val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -338,6 +344,8 @@ fun EpisodeDetailsContent( } } }, + trailers = null, + trailerOnClick = {}, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index d3c77b67..e8872180 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.SeriesName import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -55,10 +55,11 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - EpisodeQuickDetails(dto) + QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 584d5510..ee93050e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -182,4 +182,19 @@ class EpisodeViewModel release() navigationManager.navigateTo(destination) } + + fun clearChosenStreams(chosenStreams: ChosenStreams?) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + item.value?.let { item -> + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item, + userPreferencesService.getCurrent(), + ) + this@EpisodeViewModel.chosenStreams.setValueOnMain(result) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt index cfa7637b..f9758d5f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/DvrSchedule.kt @@ -114,6 +114,7 @@ class DvrScheduleViewModel @Composable fun DvrSchedule( requestFocusAfterLoading: Boolean, + focusRequesterOnEmpty: FocusRequester, modifier: Modifier = Modifier, viewModel: DvrScheduleViewModel = hiltViewModel(), ) { @@ -138,7 +139,13 @@ fun DvrSchedule( var showDialog by remember { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } if (requestFocusAfterLoading) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + if (active.isNotEmpty() || recordings.isNotEmpty()) { + focusRequester.tryRequestFocus() + } else { + focusRequesterOnEmpty.tryRequestFocus() + } + } } DvrScheduleContent( activeRecordings = active, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt index 9bce1dfe..4276330d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/LiveTvViewModel.kt @@ -1,7 +1,10 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.content.Context +import android.text.format.DateUtils +import androidx.compose.runtime.Stable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.buildAnnotatedString import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel @@ -15,8 +18,10 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode +import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.roundMinutes import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.ExceptionHandler @@ -50,6 +55,7 @@ import org.jellyfin.sdk.model.api.request.GetLiveTvChannelsRequest import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.time.LocalDateTime +import java.time.ZoneId import java.time.temporal.ChronoUnit import java.util.UUID import javax.inject.Inject @@ -58,6 +64,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds +import kotlin.time.toKotlinDuration const val MAX_HOURS = 48L @@ -528,6 +535,7 @@ data class TvChannel( val imageUrl: String?, ) +@Stable data class TvProgram( val id: UUID, val channelId: UUID, @@ -548,6 +556,49 @@ data class TvProgram( ) { val isFake = category == ProgramCategory.FAKE + val quickDetails by lazy { + val now = LocalDateTime.now() + buildAnnotatedString { + val differentDay = start.toLocalDate() != now.toLocalDate() + val time = + DateUtils.formatDateRange( + WholphinApplication.instance, + start + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + end + .atZone(ZoneId.systemDefault()) + .toInstant() + .epochSecond * 1000, + DateUtils.FORMAT_SHOW_TIME or if (differentDay) DateUtils.FORMAT_SHOW_WEEKDAY else 0, + ) + append(time) + dot() + + if (!isFake) { + duration + .roundMinutes + .toString() + .let(::append) + dot() + if (now.isAfter(start) && now.isBefore(end)) { + java.time.Duration + .between(now, end) + .toKotlinDuration() + .roundMinutes + .let { append("$it left") } + dot() + } + seasonEpisode?.let { "S${it.season} E${it.episode}" }?.let { + append(it) + dot() + } + officialRating?.let(::append) + } + } + } + companion object { private val NO_DATA = WholphinApplication.instance.getString(R.string.no_data) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 68c62063..037a5ae7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -56,7 +57,6 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import eu.wewox.programguide.ProgramGuide import eu.wewox.programguide.ProgramGuideDimensions @@ -289,22 +289,8 @@ fun TvGuideGridContent( var focusedItem by rememberPosition(RowColumn(0, 0)) val focusedChannelIndex = focusedItem.row - val focusedProgramIndex = - remember(programs.range, focusedItem) { - focusedItem.let { focus -> - (programs.range.first.. + (programs.range.first..(null) } var moreDialog by remember { mutableStateOf(null) } @@ -144,12 +140,12 @@ fun MovieDetails( LoadingState.Success -> { item?.let { movie -> - LifecycleStartEffect(destination.itemId) { + LifecycleResumeEffect(destination.itemId) { viewModel.maybePlayThemeSong( destination.itemId, preferences.appPreferences.interfacePreferences.playThemeSongs, ) - onStopOrDispose { + onPauseOrDispose { viewModel.release() } } @@ -178,7 +174,6 @@ fun MovieDetails( Destination.Playback( movie.id, it.inWholeMilliseconds, - movie, ), ) }, @@ -204,6 +199,7 @@ fun MovieDetails( favorite = movie.data.userData?.isFavorite ?: false, seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = moreActions, onChooseVersion = { chooseVersion = @@ -220,6 +216,7 @@ fun MovieDetails( moreDialog = null }, onChooseTracks = { type -> + viewModel.streamChoiceService .chooseSource( movie.data, @@ -230,6 +227,12 @@ fun MovieDetails( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( movie, @@ -252,6 +255,9 @@ fun MovieDetails( files = movie.data.mediaSources.orEmpty(), ) }, + onClearChosenStreams = { + viewModel.clearChosenStreams(chosenStreams) + }, ), ) }, @@ -299,6 +305,10 @@ fun MovieDetails( onClickExtra = { index, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, modifier = modifier, ) } @@ -359,6 +369,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun MovieDetailsContent( @@ -370,6 +381,7 @@ fun MovieDetailsContent( trailers: List, extras: List, similar: List, + discovered: List, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -381,19 +393,20 @@ fun MovieDetailsContent( onLongClickPerson: (Int, Person) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val dto = movie.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO val bringIntoViewRequester = remember { BringIntoViewRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) + Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -438,6 +451,11 @@ fun MovieDetailsContent( } } }, + trailers = trailers, + trailerOnClick = { + position = TRAILER_ROW + trailerOnClick.invoke(it) + }, modifier = Modifier .fillMaxWidth() @@ -465,21 +483,6 @@ fun MovieDetailsContent( ) } } - if (trailers.isNotEmpty()) { - item { - TrailerRow( - trailers = trailers, - onClickTrailer = { - position = TRAILER_ROW - trailerOnClick.invoke(it) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[TRAILER_ROW]), - ) - } - } if (chapters.isNotEmpty()) { item { ChapterRow( @@ -515,6 +518,14 @@ fun MovieDetailsContent( } if (similar.isNotEmpty()) { item { + val imageHeight = + remember(movie.type) { + if (movie.type == BaseItemKind.MOVIE) { + Cards.height2x3 + } else { + Cards.heightEpisode + } + } ItemRow( title = stringResource(R.string.more_like_this), items = similar, @@ -533,7 +544,7 @@ fun MovieDetailsContent( onLongClick = onLongClick, modifier = mod, showImageOverlay = true, - imageHeight = Cards.height2x3, + imageHeight = imageHeight, imageWidth = Dp.Unspecified, ) }, @@ -544,79 +555,22 @@ fun MovieDetailsContent( ) } } - } - } -} - -@Composable -fun TrailerRow( - trailers: List, - onClickTrailer: (Trailer) -> Unit, - modifier: Modifier = Modifier, -) { - val state = rememberLazyListState() - val firstFocus = remember { FocusRequester() } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = modifier, - ) { - Text( - text = stringResource(R.string.trailers), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - LazyRow( - state = state, - horizontalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - modifier = - Modifier - .fillMaxWidth() - .focusRestorer(firstFocus), - ) { - itemsIndexed(trailers) { index, item -> - val cardModifier = - if (index == 0) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - } - when (item) { - is LocalTrailer -> { - SeasonCard( - item = item.baseItem, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - showImageOverlay = false, - modifier = cardModifier, - ) - } - - is RemoteTrailer -> { - val subtitle = - when (item.url.toUri().host) { - "youtube.com", "www.youtube.com" -> "YouTube" - else -> null - } - SeasonCard( - title = item.name, - subtitle = subtitle, - name = item.name, - imageUrl = null, - isFavorite = false, - isPlayed = false, - unplayedItemCount = 0, - playedPercentage = 0.0, - onClick = { onClickTrailer.invoke(item) }, - onLongClick = {}, - modifier = cardModifier, - showImageOverlay = false, - imageHeight = Cards.height2x3, - imageWidth = Dp.Unspecified, - ) - } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index c976e79b..60451804 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -24,8 +24,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.letNotEmpty @@ -65,7 +65,11 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 4.dp - MovieQuickDetails(dto, Modifier.padding(bottom = padding)) + QuickDetails( + movie.ui.quickDetails, + movie.timeRemainingOrRuntime, + Modifier.padding(bottom = padding), + ) dto.genres?.letNotEmpty { GenreText(it, Modifier.padding(bottom = padding)) @@ -73,6 +77,7 @@ fun MovieDetailsHeader( VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = movie.data.mediaSourceCount ?: 0, modifier = Modifier.padding(bottom = padding), ) dto.taglines?.firstOrNull()?.let { tagline -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index be62b4aa..8454f0a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -19,12 +20,14 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler @@ -39,6 +42,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -53,6 +58,7 @@ class MovieViewModel @AssistedInject constructor( private val api: ApiClient, + private val seerrService: SeerrService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -80,6 +86,7 @@ class MovieViewModel val extras = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) + val discovered = MutableStateFlow>(listOf()) init { init() @@ -116,16 +123,19 @@ class MovieViewModel item, userPreferencesService.getCurrent(), ) + val remoteTrailers = trailerService.getRemoteTrailers(item) withContext(Dispatchers.Main) { this@MovieViewModel.item.value = item chosenStreams.value = result + this@MovieViewModel.trailers.value = remoteTrailers loading.value = LoadingState.Success backdropService.submit(item) } viewModelScope.launchIO { - val trailers = trailerService.getTrailers(item) - withContext(Dispatchers.Main) { - this@MovieViewModel.trailers.value = trailers + trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> + withContext(Dispatchers.Main) { + this@MovieViewModel.trailers.value = localTrailers + remoteTrailers + } } } viewModelScope.launchIO { @@ -136,6 +146,10 @@ class MovieViewModel val extras = extrasService.getExtras(item.id) this@MovieViewModel.extras.setValueOnMain(extras) } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } withContext(Dispatchers.Main) { chapters.value = Chapter.fromDto(item.data, api) @@ -243,4 +257,19 @@ class MovieViewModel release() navigationManager.navigateTo(destination) } + + fun clearChosenStreams(chosenStreams: ChosenStreams?) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + item.value?.let { item -> + val result = + itemPlaybackRepository.getSelectedTracks( + itemId, + item, + userPreferencesService.getCurrent(), + ) + this@MovieViewModel.chosenStreams.setValueOnMain(result) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index ddaedd88..9b6d81df 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -45,6 +45,8 @@ fun FocusedEpisodeFooter( watchOnClick = watchOnClick, favoriteOnClick = favoriteOnClick, buttonOnFocusChanged = buttonOnFocusChanged, + trailers = null, + trailerOnClick = {}, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index 5b25783b..ff0ddade 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.EpisodeName -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.VideoStreamDetails @Composable @@ -33,11 +33,14 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier) - EpisodeQuickDetails(dto) + ep?.ui?.quickDetails?.let { + QuickDetails(it, ep.timeRemainingOrRuntime) + } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, + numberOfVersions = dto.mediaSourceCount ?: 0, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 43fef40a..305c6451 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,7 +16,7 @@ import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -35,17 +35,19 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow @@ -61,7 +63,8 @@ import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.Optional import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.TrailerButton import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -70,11 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson -import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt -import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -105,6 +109,7 @@ fun SeriesDetails( val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } @@ -125,12 +130,10 @@ fun SeriesDetails( LoadingState.Success -> { item?.let { item -> - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() + + onPauseOrDispose { viewModel.release() } } @@ -209,6 +212,10 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, moreActions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -282,6 +289,7 @@ private const val PEOPLE_ROW = SEASONS_ROW + 1 private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val EXTRAS_ROW = TRAILER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun SeriesDetailsContent( @@ -292,6 +300,7 @@ fun SeriesDetailsContent( trailers: List, extras: List, people: List, + discovered: List, played: Boolean, favorite: Boolean, onClickItem: (Int, BaseItem) -> Unit, @@ -304,6 +313,7 @@ fun SeriesDetailsContent( trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -311,11 +321,9 @@ fun SeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - focusRequesters.getOrNull(position)?.tryRequestFocus() - } + RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } Box( @@ -414,6 +422,18 @@ fun SeriesDetailsContent( } }, ) + TrailerButton( + trailers = trailers, + trailerOnClick = trailerOnClick, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) } } item { @@ -475,21 +495,6 @@ fun SeriesDetailsContent( ) } } - if (trailers.isNotEmpty()) { - item { - TrailerRow( - trailers = trailers, - onClickTrailer = { - position = TRAILER_ROW - trailerOnClick.invoke(it) - }, - modifier = - Modifier - .fillMaxWidth() - .focusRequester(focusRequesters[TRAILER_ROW]), - ) - } - } if (extras.isNotEmpty()) { item { ExtrasRow( @@ -552,6 +557,24 @@ fun SeriesDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } @@ -590,7 +613,7 @@ fun SeriesDetailsHeader( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - SeriesQuickDetails(dto) + QuickDetails(series.ui.quickDetails, null) dto.genres?.letNotEmpty { GenreText(it) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 9377aa1d..d95a5bbb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -16,11 +16,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.map import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -37,12 +39,12 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode -import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.PersonKind import org.jellyfin.sdk.model.extensions.ticks @@ -158,25 +160,26 @@ fun SeriesOverview( LoadingState.Success -> { series?.let { series -> - LaunchedEffect(Unit) { + RequestOrRestoreFocus( when (rowFocused) { - EPISODE_ROW -> episodeRowFocusRequester.tryRequestFocus() - CAST_AND_CREW_ROW -> castCrewRowFocusRequester.tryRequestFocus() - GUEST_STAR_ROW -> guestStarRowFocusRequester.tryRequestFocus() - } - } - LifecycleStartEffect(destination.itemId) { - viewModel.maybePlayThemeSong( - destination.itemId, - preferences.appPreferences.interfacePreferences.playThemeSongs, - ) - onStopOrDispose { + EPISODE_ROW -> episodeRowFocusRequester + CAST_AND_CREW_ROW -> castCrewRowFocusRequester + GUEST_STAR_ROW -> guestStarRowFocusRequester + else -> episodeRowFocusRequester + }, + "series_overview", + ) + LifecycleResumeEffect(destination.itemId) { + viewModel.onResumePage() + + onPauseOrDispose { viewModel.release() } } fun buildMoreForEpisode( ep: BaseItem, + chosenStreams: ChosenStreams?, fromLongClick: Boolean, ): DialogParams = DialogParams( @@ -190,6 +193,7 @@ fun SeriesOverview( favorite = ep.data.userData?.isFavorite ?: false, seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), + canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -237,6 +241,12 @@ fun SeriesOverview( context = context, streams = source.mediaStreams.orEmpty(), type = type, + currentIndex = + if (type == MediaStreamType.AUDIO) { + chosenStreams?.audioStream?.index + } else { + chosenStreams?.subtitleStream?.index + }, onClick = { trackIndex -> viewModel.saveTrackSelection( ep, @@ -257,6 +267,9 @@ fun SeriesOverview( files = ep.data.mediaSources.orEmpty(), ) }, + onClearChosenStreams = { + viewModel.clearChosenStreams(ep, chosenStreams) + }, ), ) @@ -297,12 +310,11 @@ fun SeriesOverview( Destination.Playback( it.id, resumePosition.inWholeMilliseconds, - it, ), ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, true) + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -312,7 +324,6 @@ fun SeriesOverview( Destination.Playback( it.id, resume.inWholeMilliseconds, - it, ), ) } @@ -331,7 +342,7 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, false) + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) } }, overviewOnClick = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 3cf1f598..dc5aaa95 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -49,7 +49,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -60,6 +59,7 @@ import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.logTab import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp +import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.rememberDelayedNestedScroll import kotlinx.coroutines.launch @@ -107,6 +107,17 @@ fun SeriesOverviewContent( val scrollState = rememberScrollState() val scrollConnection = rememberDelayedNestedScroll() + var requestFocusAfterSeason by remember { mutableStateOf(false) } + + val seasonStr = stringResource(R.string.tv_season) + val tabs = + seasons.map { season -> + season?.name + ?: season?.data?.indexNumber?.let { "$seasonStr $it" } + ?: "" + } + val focusRequesters = remember(seasons) { List(seasons.size) { FocusRequester() } } + Box( modifier = modifier @@ -138,16 +149,13 @@ fun SeriesOverviewContent( } TabRow( selectedTabIndex = selectedTabIndex, - tabs = - seasons.mapNotNull { - it?.name - ?: it?.data?.indexNumber?.let { stringResource(R.string.tv_season) + " $it" } - ?: "" - }, + tabs = tabs, onClick = { selectedTabIndex = it onChangeSeason.invoke(it) + requestFocusAfterSeason = true }, + focusRequesters = focusRequesters, modifier = Modifier .focusRequester(tabRowFocusRequester) @@ -181,13 +189,15 @@ fun SeriesOverviewContent( } is EpisodeList.Success -> { - val state = rememberLazyListState() - OneTimeLaunchedEffect { - if (state.firstVisibleItemIndex != position.episodeRowIndex) { - state.scrollToItem(position.episodeRowIndex) + if (requestFocusAfterSeason) { + // Changing seasons, so move focus once the new episodes are loaded + LaunchedEffect(Unit) { + firstItemFocusRequester.tryRequestFocus() + requestFocusAfterSeason = false } - firstItemFocusRequester.tryRequestFocus() } + val state = rememberLazyListState(position.episodeRowIndex) + var epPosition by rememberInt(position.episodeRowIndex) LazyRow( state = state, horizontalArrangement = Arrangement.spacedBy(16.dp), @@ -195,7 +205,7 @@ fun SeriesOverviewContent( modifier = Modifier .focusRestorer(firstItemFocusRequester) - .focusRequester(episodeRowFocusRequester) +// .focusRequester(episodeRowFocusRequester) .onFocusChanged { cardRowHasFocus = it.hasFocus }, @@ -221,19 +231,23 @@ fun SeriesOverviewContent( playPercent = episode?.data?.userData?.playedPercentage ?: 0.0, - onClick = { if (episode != null) onClick.invoke(episode) }, + onClick = { + epPosition = episodeIndex + if (episode != null) onClick.invoke(episode) + }, onLongClick = { - if (episode != null) { - onLongClick.invoke( - episode, - ) - } + epPosition = episodeIndex + if (episode != null) onLongClick.invoke(episode) }, modifier = Modifier .ifElse( episodeIndex == position.episodeRowIndex, - Modifier.focusRequester(firstItemFocusRequester), + Modifier + .focusRequester(firstItemFocusRequester), + ).ifElse( + episodeIndex == epPosition, + Modifier.focusRequester(episodeRowFocusRequester), ).ifElse( episodeIndex != position.episodeRowIndex, Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index cdcc8c30..4e5f33c7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -9,16 +9,16 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer -import com.github.damontecres.wholphin.preferences.ThemeSongVolume -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -85,6 +85,7 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val seerrService: SeerrService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -98,7 +99,6 @@ class SeriesViewModel ): SeriesViewModel } - private lateinit var prefs: UserPreferences val loading = MutableLiveData(LoadingState.Loading) val seasons = MutableLiveData>(listOf()) val episodes = MutableLiveData(EpisodeList.Loading) @@ -109,6 +109,7 @@ class SeriesViewModel val similar = MutableLiveData>() val peopleInEpisode = MutableLiveData(PeopleInItem()) + val discovered = MutableStateFlow>(listOf()) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -119,7 +120,6 @@ class SeriesViewModel "Error loading series $seriesId", ) + Dispatchers.IO, ) { - this@SeriesViewModel.prefs = userPreferencesService.getCurrent() Timber.v("Start") val item = fetchItem(seriesId) backdropService.submit(item) @@ -164,12 +164,13 @@ class SeriesViewModel } ?: 0 Timber.v("Got initial season index: $index") position.update { - it.copy(seasonTabIndex = index) + it.copy(seasonTabIndex = index.coerceAtLeast(0)) } } } - + val remoteTrailers = trailerService.getRemoteTrailers(item) withContext(Dispatchers.Main) { + this@SeriesViewModel.trailers.value = remoteTrailers this@SeriesViewModel.position.update { it.copy( episodeRowIndex = @@ -182,9 +183,10 @@ class SeriesViewModel } if (seriesPageType == SeriesPageType.DETAILS) { viewModelScope.launchIO { - val trailers = trailerService.getTrailers(item) - withContext(Dispatchers.Main) { - this@SeriesViewModel.trailers.value = trailers + trailerService.getLocalTrailers(item).letNotEmpty { localTrailers -> + withContext(Dispatchers.Main) { + this@SeriesViewModel.trailers.value = localTrailers + remoteTrailers + } } } viewModelScope.launchIO { @@ -211,21 +213,23 @@ class SeriesViewModel this@SeriesViewModel.similar.setValueOnMain(similar) } } + viewModelScope.launchIO { + val results = seerrService.similar(item).orEmpty() + discovered.update { results } + } } } } - /** - * If the series has a theme song & app settings allow, play it - */ - fun maybePlayThemeSong( - seriesId: UUID, - playThemeSongs: ThemeSongVolume, - ) { + fun onResumePage() { viewModelScope.launchIO { - themeSongPlayer.playThemeFor(seriesId, playThemeSongs) - addCloseable { - themeSongPlayer.stop() + item.value?.let { + backdropService.submit(it) + val playThemeSongs = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(seriesId, playThemeSongs) } } } @@ -287,6 +291,7 @@ class SeriesViewModel fields = listOf( ItemFields.MEDIA_SOURCES, + ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_STREAMS, ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, @@ -514,6 +519,16 @@ class SeriesViewModel } } } + + fun clearChosenStreams( + item: BaseItem, + chosenStreams: ChosenStreams?, + ) { + viewModelScope.launchIO { + itemPlaybackRepository.deleteChosenStreams(chosenStreams) + lookUpChosenTracks(item.id, item) + } + } } sealed interface EpisodeList { @@ -551,7 +566,10 @@ private suspend fun findIndexOf( val index = if (targetId != null && (targetNum == null || targetNum !in pager.indices)) { // No hint info, so have to check everything - pager.indexOfBlocking { equalsNotNull(it?.id, targetId) } + pager.indexOfBlocking { + equalsNotNull(it?.indexNumber, targetNum) || + equalsNotNull(it?.id, targetId) + } } else if (targetNum != null && targetNum in pager.indices) { // Start searching from the season number and choose direction from there val num = pager.getBlocking(targetNum)?.indexNumber diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt new file mode 100644 index 00000000..4d3bdc42 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -0,0 +1,111 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.TabRow +import com.github.damontecres.wholphin.ui.logTab +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun DiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + preferencesViewModel: PreferencesViewModel = hiltViewModel(), +) { + val rememberedTabIndex = + remember { preferencesViewModel.getRememberedTab(preferences, NavDrawerItem.Discover.id, 0) } + + val tabs = + listOf( + stringResource(R.string.discover), + stringResource(R.string.request), + ) + var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } + val focusRequester = remember { FocusRequester() } + val tabFocusRequesters = remember(tabs) { List(tabs.size) { FocusRequester() } } + + val firstTabFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstTabFocusRequester.tryRequestFocus() } + + LaunchedEffect(selectedTabIndex) { + logTab("discover", selectedTabIndex) + preferencesViewModel.saveRememberedTab(preferences, NavDrawerItem.Discover.id, selectedTabIndex) + preferencesViewModel.backdropService.clearBackdrop() + } + + var showHeader by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(Unit) { focusRequester.tryRequestFocus("page") } + Column( + modifier = modifier, + ) { + AnimatedVisibility( + showHeader, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + ) { + TabRow( + selectedTabIndex = selectedTabIndex, + modifier = + Modifier + .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .focusRequester(firstTabFocusRequester), + tabs = tabs, + onClick = { selectedTabIndex = it }, + focusRequesters = tabFocusRequesters, + ) + } + when (selectedTabIndex) { + // Discover + 0 -> { + SeerrDiscoverPage( + preferences = preferences, + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + // Requests + 1 -> { + SeerrRequestsPage( + focusRequesterOnEmpty = tabFocusRequesters.getOrNull(selectedTabIndex), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + + else -> { + ErrorMessage("Invalid tab index $selectedTabIndex", null) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt new file mode 100644 index 00000000..fc58bf2d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -0,0 +1,335 @@ +package com.github.damontecres.wholphin.ui.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.listToDotString +import com.github.damontecres.wholphin.ui.main.HomePageHeader +import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import com.google.common.cache.CacheBuilder +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import javax.inject.Inject + +@HiltViewModel +class SeerrDiscoverViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val api: ApiClient, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(DiscoverState()) + val rating = MutableStateFlow>(mapOf()) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + fetchAndUpdateState(seerrService::discoverMovies) { + this.copy(movies = DiscoverRowData(context.getString(R.string.movies), it)) + } + fetchAndUpdateState(seerrService::discoverTv) { + this.copy(tv = DiscoverRowData(context.getString(R.string.tv_shows), it)) + } + fetchAndUpdateState(seerrService::trending) { + this.copy(trending = DiscoverRowData(context.getString(R.string.trending), it)) + } + fetchAndUpdateState(seerrService::upcomingMovies) { + this.copy( + upcomingMovies = + DiscoverRowData(context.getString(R.string.upcoming_movies), it), + ) + } + fetchAndUpdateState(seerrService::upcomingTv) { + this.copy( + upcomingTv = + DiscoverRowData(context.getString(R.string.upcoming_tv), it), + ) + } + } + + private fun fetchAndUpdateState( + getData: suspend () -> List, + copyFunc: DiscoverState.(DataLoadingState>) -> DiscoverState, + ) { + viewModelScope.launchIO { + state.update { + copyFunc.invoke(it, DataLoadingState.Loading) + } + try { + val results = getData.invoke() + state.update { + copyFunc.invoke(it, DataLoadingState.Success(results)) + } + } catch (ex: Exception) { + state.update { + copyFunc.invoke(it, DataLoadingState.Error(ex)) + } + } + } + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + fetchRating(item) + } + } + } + + private val ratingCache = + CacheBuilder + .newBuilder() + .maximumSize(100) + .build() + + // TODO this is not very efficient + fun fetchRating(item: DiscoverItem) { + viewModelScope.launchIO { + val cachedResult = ratingCache.getIfPresent(item.id) + if (cachedResult != null) { + return@launchIO + } + val result = + when (item.type) { + SeerrItemType.MOVIE -> { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet( + movieId = item.id, + ), + ) + } + + SeerrItemType.TV -> { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } + + SeerrItemType.PERSON -> { + DiscoverRating(null, null) + } + + SeerrItemType.UNKNOWN -> { + DiscoverRating(null, null) + } + } + ratingCache.put(item.id, result) + rating.update { + ratingCache.asMap().toMap() + } + } + } + } + +data class DiscoverRowData( + val title: String, + val items: DataLoadingState>, +) { + companion object { + val EMPTY = DiscoverRowData("", DataLoadingState.Pending) + } +} + +data class DiscoverState( + val movies: DiscoverRowData = DiscoverRowData.EMPTY, + val tv: DiscoverRowData = DiscoverRowData.EMPTY, + val trending: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingMovies: DiscoverRowData = DiscoverRowData.EMPTY, + val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, +) + +@Composable +fun SeerrDiscoverPage( + preferences: UserPreferences, + modifier: Modifier = Modifier, + viewModel: SeerrDiscoverViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState() + val rows = + listOf(state.trending, state.movies, state.tv, state.upcomingMovies, state.upcomingTv) + val ratingMap by viewModel.rating.collectAsState() + + val focusRequesters = remember(2) { List(rows.size) { FocusRequester() } } + var position by rememberPosition(0, -1) + val focusedItem = + remember(position) { + position.let { + (rows.getOrNull(it.row)?.items as? DataLoadingState.Success)?.data?.getOrNull(it.column) + } + } + LaunchedEffect(focusedItem) { + viewModel.updateBackdrop(focusedItem) + } + var firstFocused by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(state.trending) { + if (!firstFocused && state.trending.items is DataLoadingState.Success<*>) { + firstFocused = focusRequesters.getOrNull(0)?.tryRequestFocus("discover") == true + } + } + + Column( + modifier = modifier, + ) { + val details = + remember(focusedItem, ratingMap) { + buildList { + focusedItem + ?.releaseDate + ?.year + ?.toString() + ?.let(::add) + }.let { + val rating = focusedItem?.id?.let { ratingMap[it] } + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + } + } + HomePageHeader( + title = focusedItem?.title, + subtitle = focusedItem?.subtitle, + overview = focusedItem?.overview, + overviewTwoLines = true, + quickDetails = details, + timeRemaining = null, + modifier = + Modifier + .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) + .fillMaxHeight(.25f), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +fun DiscoverRow( + row: DiscoverRowData, + onClickItem: (Int, DiscoverItem) -> Unit, + onLongClickItem: (Int, DiscoverItem) -> Unit, + onCardFocus: (Int) -> Unit, + focusRequester: FocusRequester, + modifier: Modifier = Modifier, +) { + when (val state = row.items) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = row.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is DataLoadingState.Success> -> { + ItemRow( + title = row.title, + items = state.data, + onClickItem = onClickItem, + onLongClickItem = onLongClickItem, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = + mod.onFocusChanged { + if (it.isFocused) { + onCardFocus.invoke(index) + } + }, + ) + }, + modifier = modifier.focusRequester(focusRequester), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt new file mode 100644 index 00000000..6746950a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -0,0 +1,219 @@ +package com.github.damontecres.wholphin.ui.discover + +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MediaRequest +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.DataLoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SeerrRequestsViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, + private val backdropService: BackdropService, + ) : ViewModel() { + val state = MutableStateFlow(SeerrRequestsState.EMPTY) + + init { + viewModelScope.launchIO { + backdropService.clearBackdrop() + } + seerrServerRepository.current + .onEach { user -> + state.update { it.copy(requests = DataLoadingState.Loading) } + if (user != null) { + val semaphore = Semaphore(3) + val mediaRequests = + seerrService.api.requestApi + .requestGet() + .results + .orEmpty() + val requests = + mediaRequests.mapNotNull { request -> + if (request.media?.tmdbId != null) { + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + val type = SeerrItemType.fromString(request.type) + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet( + movieId = request.media.tmdbId, + ).let { DiscoverItem(it) } + } + + SeerrItemType.TV -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = request.media.tmdbId) + .let { DiscoverItem(it) } + } + + SeerrItemType.PERSON -> { + null + } + + SeerrItemType.UNKNOWN -> { + null + } + }?.let { RequestGridItem(request, it) } + } + } + } else { + Timber.v("No TMDB ID for request %s", request.id) + null + } + } + val results = requests.awaitAll().filterNotNull() + + state.update { it.copy(requests = DataLoadingState.Success(results)) } + } + }.launchIn(viewModelScope) + } + + fun updateBackdrop(item: DiscoverItem?) { + viewModelScope.launchIO { + if (item != null) { + backdropService.submit("discover_${item.id}", item.backDropUrl) + } + } + } + } + +data class SeerrRequestsState( + val requests: DataLoadingState>, +) { + companion object { + val EMPTY = SeerrRequestsState(DataLoadingState.Pending) + } +} + +data class RequestGridItem( + val request: MediaRequest, + val item: DiscoverItem, +) : CardGridItem { + override val gridId: String = request.id.toString() + override val playable: Boolean = false + override val sortName: String = request.updatedAt ?: "0000" +} + +@Composable +fun SeerrRequestsPage( + focusRequesterOnEmpty: FocusRequester?, + modifier: Modifier = Modifier, + viewModel: SeerrRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsState(SeerrRequestsState.EMPTY) + + when (val state = state.requests) { + is DataLoadingState.Error -> { + ErrorMessage(state.message, state.exception, modifier.focusable()) + } + + DataLoadingState.Loading, + DataLoadingState.Pending, + -> { + LoadingPage(modifier.focusable()) + } + + is DataLoadingState.Success> -> { + val focusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (state.data.isNotEmpty()) { + focusRequester.tryRequestFocus() + } else { + focusRequesterOnEmpty?.tryRequestFocus() + } + } + Column(modifier = modifier) { +// Text( +// text = stringResource(R.string.request), +// style = MaterialTheme.typography.displaySmall, +// color = MaterialTheme.colorScheme.onBackground, +// textAlign = TextAlign.Center, +// modifier = Modifier.fillMaxWidth(), +// ) + if (state.data.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxSize(), + ) + } else { + CardGrid( + pager = state.data, + onClickItem = { index: Int, item: RequestGridItem -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item.item)) + }, + onLongClickItem = { index: Int, item: RequestGridItem -> + }, + onClickPlay = { _, item -> + }, + letterPosition = { c: Char -> 0 }, + gridFocusRequester = focusRequester, + showJumpButtons = false, + showLetterButtons = false, + spacing = 16.dp, + cardContent = @Composable { item, onClick, onLongClick, mod -> + DiscoverItemCard( + item = item?.item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + columns = 6, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 67b4200b..d71c0c1f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.main import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,7 +25,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -48,28 +49,27 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.abbreviateNumber import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup -import com.github.damontecres.wholphin.ui.components.EpisodeQuickDetails +import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.components.MovieQuickDetails -import com.github.damontecres.wholphin.ui.components.SeriesQuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.data.RowColumnSaver import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -78,6 +78,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import java.util.UUID +import kotlin.time.Duration @Composable fun HomePage( @@ -208,50 +209,39 @@ fun HomePageContent( onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val firstRow = - remember { - homeRows - .indexOfFirst { - when (it) { - is HomeRowLoadingState.Error -> false - is HomeRowLoadingState.Loading -> true - is HomeRowLoadingState.Pending -> true - is HomeRowLoadingState.Success -> it.items.isNotEmpty() - } - }.coerceAtLeast(0) - } - var position by rememberSaveable(stateSaver = RowColumnSaver) { - mutableStateOf(RowColumn(firstRow, 0)) - } - var focusedItem = + var position by rememberPosition() + val focusedItem = position.let { (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } val listState = rememberLazyListState() - val focusRequester = remember { FocusRequester() } - val positionFocusRequester = remember { FocusRequester() } - var focused by remember { mutableStateOf(false) } + val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } + var firstFocused by remember { mutableStateOf(false) } LaunchedEffect(homeRows) { - if (!focused) { - homeRows - .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - .takeIf { it >= 0 } - ?.let { - positionFocusRequester.tryRequestFocus() - delay(50) - listState.animateScrollToItem(position.row) - focused = true - } + if (!firstFocused) { + if (position.row >= 0) { + rowFocusRequesters[position.row].tryRequestFocus() + firstFocused = true + } else { + // Waiting for the first home row to load, then focus on it + homeRows + .indexOfFirst { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + .takeIf { it >= 0 } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } LaunchedEffect(position) { listState.animateScrollToItem(position.row) } - LaunchedEffect(focusedItem) { - focusedItem?.let(onUpdateBackdrop) + LaunchedEffect(onUpdateBackdrop, focusedItem) { + focusedItem?.let { onUpdateBackdrop.invoke(it) } } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { @@ -274,16 +264,7 @@ fun HomePageContent( ), modifier = Modifier - .focusRestorer() - .onKeyEvent { - val item = focusedItem - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, + .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> when (val r = row) { @@ -346,27 +327,31 @@ fun HomePageContent( onClickItem = { index, item -> onClickItem.invoke(RowColumn(rowIndex, index), item) }, - cardOnFocus = { isFocused, index -> - if (isFocused) { - focusedItem = row.items.getOrNull(index) - position = RowColumn(rowIndex, index) - } - }, onLongClickItem = { index, item -> onLongClickItem.invoke(RowColumn(rowIndex, index), item) }, modifier = Modifier .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), cardContent = { index, item, cardModifier, onClick, onLongClick -> + val cornerText = + remember(item) { + item?.data?.indexNumber?.let { "E$it" } + ?: item + ?.data + ?.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) } + } BannerCard( name = item?.data?.seriesName ?: item?.name, item = item, aspectRatio = AspectRatios.TALL, - cornerText = - item?.data?.indexNumber?.let { "E$it" } - ?: item?.data?.childCount?.let { if (it > 0) it.toString() else null }, + cornerText = cornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = @@ -376,29 +361,32 @@ fun HomePageContent( onLongClick = onLongClick, modifier = cardModifier - .ifElse( - focusedItem == item, - Modifier.focusRequester(focusRequester), - ).ifElse( - RowColumn(rowIndex, index) == position, - Modifier.focusRequester( - positionFocusRequester, - ), - ).onFocusChanged { + .onFocusChanged { if (it.isFocused) { + position = RowColumn(rowIndex, index) +// item?.let(onUpdateBackdrop) + } + if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = homeRows .subList(0, rowIndex) .count { it is HomeRowLoadingState.Success && it.items.isEmpty() } - onFocusPosition?.invoke( + onFocusPosition.invoke( RowColumn( rowIndex - nonEmptyRowBefore, index, ), ) } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke(position, item) + return@onKeyEvent true + } + return@onKeyEvent false }, interactionSource = null, cardHeight = Cards.height2x3, @@ -437,62 +425,71 @@ fun HomePageHeader( modifier: Modifier = Modifier, ) { item?.let { + val isEpisode = item.type == BaseItemKind.EPISODE val dto = item.data - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), + HomePageHeader( + title = item.title, + subtitle = if (isEpisode) dto.name else null, + overview = dto.overview, + overviewTwoLines = isEpisode, + quickDetails = item.ui.quickDetails, + timeRemaining = item.timeRemainingOrRuntime, modifier = modifier, + ) + } +} + +@Composable +fun HomePageHeader( + title: String?, + subtitle: String?, + overview: String?, + overviewTwoLines: Boolean, + quickDetails: AnnotatedString, + timeRemaining: Duration?, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .fillMaxWidth(.6f) + .fillMaxHeight(), ) { - item.title?.let { - Text( - text = it, - style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), - ) + subtitle?.let { + EpisodeName(it) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .fillMaxWidth(.6f) - .fillMaxHeight(), - ) { - val isEpisode = item.type == BaseItemKind.EPISODE - val subtitle = if (isEpisode) dto.name else null - val overview = dto.overview - subtitle?.let { - Text( - text = subtitle, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - when (item.type) { - BaseItemKind.EPISODE -> EpisodeQuickDetails(dto, Modifier) - BaseItemKind.SERIES -> SeriesQuickDetails(dto, Modifier) - else -> MovieQuickDetails(dto, Modifier) - } - val overviewModifier = - Modifier - .padding(0.dp) - .height(48.dp + if (!isEpisode) 12.dp else 0.dp) - .width(400.dp) - if (overview.isNotNullOrBlank()) { - Text( - text = overview, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = if (isEpisode) 2 else 3, - overflow = TextOverflow.Ellipsis, - modifier = overviewModifier, - ) - } else { - Spacer(overviewModifier) - } + QuickDetails(quickDetails, timeRemaining) + val overviewModifier = + Modifier + .padding(0.dp) + .height(48.dp + if (!overviewTwoLines) 12.dp else 0.dp) + .width(400.dp) + if (overview.isNotNullOrBlank()) { + Text( + text = overview, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = if (overviewTwoLines) 2 else 3, + overflow = TextOverflow.Ellipsis, + modifier = overviewModifier, + ) + } else { + Spacer(overviewModifier) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index ad1c2970..e2704ad8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain @@ -21,34 +21,18 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState -import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Semaphore -import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.itemsApi -import org.jellyfin.sdk.api.client.extensions.tvShowsApi -import org.jellyfin.sdk.api.client.extensions.userLibraryApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest -import org.jellyfin.sdk.model.api.request.GetNextUpRequest -import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest import timber.log.Timber -import java.time.LocalDateTime import java.util.UUID import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class HomeViewModel @@ -61,6 +45,7 @@ class HomeViewModel val navDrawerItemRepository: NavDrawerItemRepository, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, + private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, ) : ViewModel() { val loadingState = MutableLiveData(LoadingState.Pending) @@ -91,7 +76,9 @@ class HomeViewModel ), ) { Timber.d("init HomeViewModel") - backdropService.clearBackdrop() + if (reload) { + backdropService.clearBackdrop() + } serverRepository.currentUserDto.value?.let { userDto -> val includedIds = @@ -99,10 +86,9 @@ class HomeViewModel .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) .filter { it is ServerNavDrawerItem } .map { (it as ServerNavDrawerItem).itemId } - // TODO data is fetched all together which may be slow for large servers - val resume = getResume(userDto.id, limit, true) + val resume = latestNextUpService.getResume(userDto.id, limit, true) val nextUp = - getNextUp( + latestNextUpService.getNextUp( userDto.id, limit, prefs.enableRewatchingNextUp, @@ -111,13 +97,7 @@ class HomeViewModel val watching = buildList { if (prefs.combineContinueNext) { - val items = - buildCombinedNextUp( - viewModelScope, - datePlayedService, - resume, - nextUp, - ) + val items = latestNextUpService.buildCombined(resume, nextUp) add( HomeRowLoadingState.Success( title = context.getString(R.string.continue_watching), @@ -144,7 +124,7 @@ class HomeViewModel } } - val latest = getLatest(userDto, limit, includedIds) + val latest = latestNextUpService.getLatest(userDto, limit, includedIds) val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } withContext(Dispatchers.Main) { @@ -154,127 +134,13 @@ class HomeViewModel } loadingState.value = LoadingState.Success } - loadLatest(latest) refreshState.setValueOnMain(LoadingState.Success) + val loadedLatest = latestNextUpService.loadLatest(latest) + this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) } } } - private suspend fun getResume( - userId: UUID, - limit: Int, - includeEpisodes: Boolean, - ): List { - val request = - GetResumeItemsRequest( - userId = userId, - fields = SlimItemFields, - limit = limit, - includeItemTypes = - if (includeEpisodes) { - supportItemKinds - } else { - supportItemKinds - .toMutableSet() - .apply { - remove(BaseItemKind.EPISODE) - } - }, - ) - val items = - api.itemsApi - .getResumeItems(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return items - } - - private suspend fun getNextUp( - userId: UUID, - limit: Int, - enableRewatching: Boolean, - enableResumable: Boolean, - ): List { - val request = - GetNextUpRequest( - userId = userId, - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = null, - limit = limit, - enableResumable = enableResumable, - enableUserData = true, - enableRewatching = enableRewatching, - ) - val nextUp = - api.tvShowsApi - .getNextUp(request) - .content - .items - .map { BaseItem.from(it, api, true) } - return nextUp - } - - private suspend fun getLatest( - user: UserDto, - limit: Int, - includedIds: List, - ): List { - val excluded = user.configuration?.latestItemsExcludes.orEmpty() - val views by api.userViewsApi.getUserViews() - val latestData = - views.items - .filter { - it.id in includedIds && it.id !in excluded && - it.collectionType in supportedLatestCollectionTypes - }.map { view -> - val title = - view.name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - val request = - GetLatestMediaRequest( - fields = SlimItemFields, - imageTypeLimit = 1, - parentId = view.id, - groupItems = true, - limit = limit, - isPlayed = null, // Server will handle user's preference - ) - LatestData(title, request) - } - - return latestData - } - - private suspend fun loadLatest(latestData: List) { - val rows = - latestData.mapNotNull { (title, request) -> - try { - val latest = - api.userLibraryApi - .getLatestMedia(request) - .content - .map { BaseItem.from(it, api, true) } - if (latest.isNotEmpty()) { - HomeRowLoadingState.Success( - title = title, - items = latest, - ) - } else { - null - } - } catch (ex: Exception) { - Timber.e(ex, "Exception fetching %s", title) - HomeRowLoadingState.Error( - title = title, - exception = ex, - ) - } - } - latestRows.setValueOnMain(rows) - } - fun setWatched( itemId: UUID, played: Boolean, @@ -315,38 +181,3 @@ data class LatestData( val title: String, val request: GetLatestMediaRequest, ) - -suspend fun buildCombinedNextUp( - scope: CoroutineScope, - datePlayedService: DatePlayedService, - resume: List, - nextUp: List, -): List = - withContext(Dispatchers.IO) { - val start = System.currentTimeMillis() - val semaphore = Semaphore(3) - val deferred = - nextUp - .filter { it.data.seriesId != null } - .map { item -> - scope.async(Dispatchers.IO) { - try { - semaphore.withPermit { - datePlayedService.getLastPlayed(item) - } - } catch (ex: Exception) { - Timber.e(ex, "Error fetching %s", item.id) - null - } - } - } - - val nextUpLastPlayed = deferred.awaitAll() - val timestamps = mutableMapOf() - nextUp.map { it.id }.zip(nextUpLastPlayed).toMap(timestamps) - resume.forEach { timestamps[it.id] = it.data.userData?.lastPlayedDate } - val result = (resume + nextUp).sortedByDescending { timestamps[it.id] } - val duration = (System.currentTimeMillis() - start).milliseconds - Timber.v("buildCombined took %s", duration) - return@withContext result - } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt index 19b04d0b..b5839aad 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/SearchPage.kt @@ -2,12 +2,11 @@ package com.github.damontecres.wholphin.ui.main import androidx.activity.compose.BackHandler import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -25,7 +24,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController @@ -34,23 +40,33 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.EpisodeCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain import com.github.damontecres.wholphin.ui.rememberPosition +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler @@ -58,6 +74,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -72,11 +89,18 @@ class SearchViewModel constructor( val api: ApiClient, val navigationManager: NavigationManager, + private val seerrService: SeerrService, + val voiceInputManager: VoiceInputManager, ) : ViewModel() { + val voiceState = voiceInputManager.state + val soundLevel = voiceInputManager.soundLevel + val partialResult = voiceInputManager.partialResult + val movies = MutableLiveData(SearchResult.NoQuery) val series = MutableLiveData(SearchResult.NoQuery) val episodes = MutableLiveData(SearchResult.NoQuery) val collections = MutableLiveData(SearchResult.NoQuery) + val seerrResults = MutableLiveData(SearchResult.NoQuery) private var currentQuery: String? = null @@ -94,11 +118,13 @@ class SearchViewModel searchInternal(query, BaseItemKind.SERIES, series) searchInternal(query, BaseItemKind.EPISODE, episodes) searchInternal(query, BaseItemKind.BOX_SET, collections) + searchSeerr(query) } else { movies.value = SearchResult.NoQuery series.value = SearchResult.NoQuery episodes.value = SearchResult.NoQuery collections.value = SearchResult.NoQuery + seerrResults.value = SearchResult.NoQuery } } @@ -132,6 +158,24 @@ class SearchViewModel } } + private fun searchSeerr(query: String) { + viewModelScope.launchIO { + if (seerrService.active.first()) { + seerrResults.setValueOnMain(SearchResult.Searching) + val results = + seerrService + .search(query) + .map { DiscoverItem(it) } + .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } + seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) + } + } + } + + init { + addCloseable(voiceInputManager) + } + fun getHints(query: String) { // TODO // api.searchApi.getSearchHints() @@ -150,12 +194,21 @@ sealed interface SearchResult { data class Success( val items: List, ) : SearchResult + + data class SuccessSeerr( + val items: List, + ) : SearchResult } -private const val MOVIE_ROW = 0 +private const val SEARCH_ROW = 0 +private const val MOVIE_ROW = SEARCH_ROW + 1 private const val COLLECTION_ROW = MOVIE_ROW + 1 private const val SERIES_ROW = COLLECTION_ROW + 1 private const val EPISODE_ROW = SERIES_ROW + 1 +private const val SEERR_ROW = EPISODE_ROW + 1 + +/** Delay for focus to settle after voice search dialog dismisses. */ +private const val VOICE_RESULT_FOCUS_DELAY_MS = 350L @Composable fun SearchPage( @@ -170,29 +223,63 @@ fun SearchPage( val collections by viewModel.collections.observeAsState(SearchResult.NoQuery) val series by viewModel.series.observeAsState(SearchResult.NoQuery) val episodes by viewModel.episodes.observeAsState(SearchResult.NoQuery) + val seerrResults by viewModel.seerrResults.observeAsState(SearchResult.NoQuery) // val query = rememberTextFieldState() var query by rememberSaveable { mutableStateOf("") } - val focusRequester = remember { FocusRequester() } + val focusRequesters = remember { List(SEERR_ROW + 1) { FocusRequester() } } - var position by rememberPosition() + var position by rememberPosition(0, 0) var searchClicked by rememberSaveable { mutableStateOf(false) } + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + searchClicked = true + viewModel.search(searchQuery) + } LaunchedEffect(query) { - delay(750L) - viewModel.search(query) + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(query) + } + } } LaunchedEffect(Unit) { - focusRequester.tryRequestFocus() + focusRequesters.getOrNull(position.row)?.tryRequestFocus() } val onClickItem = { index: Int, item: BaseItem -> viewModel.navigationManager.navigateTo(item.destination()) } - LaunchedEffect(searchClicked, movies, collections, series, episodes) { - if (searchClicked) { - if (listOf(movies, collections, series, episodes).any { it is SearchResult.Success }) { - focusManager.moveFocus(FocusDirection.Next) - searchClicked = false + + LaunchedEffect(searchClicked, movies, collections, series, episodes, seerrResults) { + if (!searchClicked) return@LaunchedEffect + + withContext(Dispatchers.IO) { + // Want to focus on the first successful row after all of the ones before it are finished searching + val results = listOf(movies, collections, series, episodes, seerrResults) + val firstSuccess = + results.indexOfFirst { it is SearchResult.Success || it is SearchResult.SuccessSeerr } + if (firstSuccess >= 0) { + val anyBeforeSearching = + results.subList(0, firstSuccess).any { it is SearchResult.Searching } + if (!anyBeforeSearching) { + // 0-th row is the search bar + position = RowColumn(firstSuccess + 1, 0) + onMain { focusRequesters[firstSuccess + 1].tryRequestFocus() } + } } } } @@ -207,27 +294,67 @@ fun SearchPage( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), ) { - val interactionSource = remember { MutableInteractionSource() } - val focused by interactionSource.collectIsFocusedAsState() - BackHandler(focused) { - keyboardController?.hide() - focusManager.moveFocus(FocusDirection.Next) + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } } - SearchEditTextBox( - value = query, - onValueChange = { query = it }, - onSearchClick = { - viewModel.search(query) - searchClicked = true - }, + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .ifElse( - position.row < MOVIE_ROW, - Modifier.focusRequester(focusRequester), - ), - interactionSource = interactionSource, - ) + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(focusRequesters[SEARCH_ROW]), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + ) + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } } } searchResultRow( @@ -235,7 +362,7 @@ fun SearchPage( result = movies, rowIndex = MOVIE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[MOVIE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -245,7 +372,7 @@ fun SearchPage( result = collections, rowIndex = COLLECTION_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[COLLECTION_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -255,7 +382,7 @@ fun SearchPage( result = series, rowIndex = SERIES_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[SERIES_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -265,7 +392,7 @@ fun SearchPage( result = episodes, rowIndex = EPISODE_ROW, position = position, - focusRequester = focusRequester, + focusRequester = focusRequesters[EPISODE_ROW], onClickItem = onClickItem, onClickPosition = { position = it }, modifier = Modifier.fillMaxWidth(), @@ -278,16 +405,34 @@ fun SearchPage( }, onLongClick = onLongClick, imageHeight = 140.dp, - modifier = - mod - .padding(horizontal = 8.dp) - .ifElse( - position.row == EPISODE_ROW && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod.padding(horizontal = 8.dp), ) }, ) + searchResultRow( + title = context.getString(R.string.discover), + result = seerrResults, + rowIndex = SEERR_ROW, + position = position, + focusRequester = focusRequesters[SEERR_ROW], + onClickItem = { _, _ -> + // no-op + }, + onClickDiscover = { _, item -> + val dest = + if (item.jellyfinItemId != null && item.type.baseItemKind != null) { + Destination.MediaItem( + itemId = item.jellyfinItemId, + type = item.type.baseItemKind, + ) + } else { + Destination.DiscoveredItem(item) + } + viewModel.navigationManager.navigateTo(dest) + }, + onClickPosition = { position = it }, + modifier = Modifier.fillMaxWidth(), + ) } } @@ -300,6 +445,7 @@ fun LazyListScope.searchResultRow( onClickItem: (Int, BaseItem) -> Unit, onClickPosition: (RowColumn) -> Unit, modifier: Modifier = Modifier, + onClickDiscover: ((Int, DiscoverItem) -> Unit)? = null, cardContent: @Composable ( index: Int, item: BaseItem?, @@ -315,12 +461,7 @@ fun LazyListScope.searchResultRow( }, onLongClick = onLongClick, imageHeight = Cards.height2x3, - modifier = - mod - .ifElse( - position.row == rowIndex && position.column == index, - Modifier.focusRequester(focusRequester), - ), + modifier = mod, ) }, ) { @@ -360,11 +501,41 @@ fun LazyListScope.searchResultRow( items = r.items, onClickItem = onClickItem, onLongClickItem = { _, _ -> }, - modifier = modifier, + modifier = modifier.focusRequester(focusRequester), cardContent = cardContent, ) } } + + is SearchResult.SuccessSeerr -> { + if (r.items.isEmpty()) { + SearchResultPlaceholder( + title = title, + message = stringResource(R.string.no_results), + modifier = modifier, + ) + } else { + ItemRow( + title = title, + items = r.items, + onClickItem = { index, item -> + onClickPosition.invoke(RowColumn(rowIndex, index)) + onClickDiscover?.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = modifier.focusRequester(focusRequester), + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 7e7d0d2b..2ada8504 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -49,6 +49,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds +// Top scrim configuration for text readability (clock, season tabs) +private const val TOP_SCRIM_ALPHA = 0.55f +private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height + @HiltViewModel class ApplicationContentViewModel @Inject @@ -69,9 +73,11 @@ class ApplicationContentViewModel fun ApplicationContent( server: JellyfinServer, user: JellyfinUser, + startDestination: Destination, navigationManager: NavigationManager, preferences: UserPreferences, modifier: Modifier = Modifier, + enableTopScrim: Boolean = true, viewModel: ApplicationContentViewModel = hiltViewModel(), ) { val backStack: MutableList = @@ -80,7 +86,7 @@ fun ApplicationContent( user, serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()), ) { - NavBackStack(Destination.Home()) + NavBackStack(startDestination) } navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() @@ -178,6 +184,20 @@ fun ApplicationContent( .alpha(.95f) .drawWithContent { drawContent() + // Subtle top scrim for system UI readability (clock, tabs) + if (enableTopScrim) { + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } drawRect( brush = Brush.horizontalGradient( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 322b0e46..426d5328 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -6,16 +6,16 @@ import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.ui.data.SortAndDirection -import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import kotlinx.serialization.UseSerializers import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.serializer.UUIDSerializer import java.util.UUID @@ -45,7 +45,6 @@ sealed class Destination( data class SeriesOverview( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, val seasonEpisode: SeasonEpisodeIds? = null, ) : Destination() { override fun toString(): String = "SeriesOverview(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode)" @@ -55,11 +54,9 @@ sealed class Destination( data class MediaItem( val itemId: UUID, val type: BaseItemKind, - @Transient val item: BaseItem? = null, - val seasonEpisode: SeasonEpisode? = null, + val collectionType: CollectionType? = null, ) : Destination() { - override fun toString(): String = - "MediaItem(itemId=$itemId, type=$type, seasonEpisode=$seasonEpisode, collectionType=${item?.data?.collectionType})" + constructor(item: BaseItem) : this(item.id, item.type, item.data.collectionType) } @Serializable @@ -71,13 +68,10 @@ sealed class Destination( data class Playback( val itemId: UUID, val positionMs: Long, - @Transient val item: BaseItem? = null, val itemPlayback: ItemPlayback? = null, val forceTranscoding: Boolean = false, ) : Destination(true) { - override fun toString(): String = "Playback(itemId=$itemId, positionMs=$positionMs)" - - constructor(item: BaseItem) : this(item.id, item.resumeMs, item) + constructor(item: BaseItem) : this(item.id, item.resumeMs) } @Serializable @@ -109,6 +103,14 @@ sealed class Destination( @Serializable data object Favorites : Destination(false) + @Serializable + data object Discover : Destination(false) + + @Serializable + data class DiscoveredItem( + val item: DiscoverItem, + ) : Destination(false) + @Serializable data object UpdateApp : Destination(true) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 3ebd1cb1..43b8841d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -3,8 +3,10 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -20,10 +22,14 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverPersonPage +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview +import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage @@ -121,7 +127,6 @@ fun DestinationContent( CollectionFolderBoxSet( preferences = preferences, itemId = destination.itemId, - item = destination.item, recursive = false, playEnabled = true, modifier = modifier, @@ -141,7 +146,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = null, modifier = modifier, @@ -153,7 +158,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = true, recursiveOverride = null, modifier = modifier, @@ -165,7 +170,7 @@ fun DestinationContent( CollectionFolder( preferences = preferences, destination = destination, - collectionType = destination.item?.data?.collectionType, + collectionType = destination.collectionType, usePostersOverride = null, recursiveOverride = true, modifier = modifier, @@ -247,6 +252,47 @@ fun DestinationContent( Destination.Debug -> { DebugPage(preferences, modifier) } + + Destination.Discover -> { + DiscoverPage( + preferences = preferences, + modifier = modifier, + ) + } + + is Destination.DiscoveredItem -> { + when (destination.item.type) { + SeerrItemType.MOVIE -> { + DiscoverMovieDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.TV -> { + DiscoverSeriesDetails( + preferences = preferences, + destination = destination, + modifier = modifier, + ) + } + + SeerrItemType.PERSON -> { + DiscoverPersonPage( + person = destination.item, + modifier = modifier, + ) + } + + SeerrItemType.UNKNOWN -> { + Text( + text = "Unknown discover type", + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } } } @@ -292,7 +338,6 @@ fun CollectionFolder( CollectionFolderPlaylist( preferences, destination.itemId, - destination.item, true, modifier, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index ef858eb7..10c48451 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateDpAsState -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -37,11 +36,11 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalConfiguration @@ -79,6 +78,7 @@ import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome @@ -95,6 +95,8 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.model.api.CollectionType @@ -110,17 +112,25 @@ class NavDrawerViewModel val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, + private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - private var all: List? = null + // private var all: List? = null val moreLibraries = MutableLiveData>(null) val libraries = MutableLiveData>(listOf()) val selectedIndex = MutableLiveData(-1) val showMore = MutableLiveData(false) + init { + seerrServerRepository.active + .onEach { + init() + }.launchIn(viewModelScope) + } + fun init() { viewModelScope.launchIO { - val all = all ?: navDrawerItemRepository.getNavDrawerItems() - this@NavDrawerViewModel.all = all + val all = navDrawerItemRepository.getNavDrawerItems() +// this@NavDrawerViewModel.all = all val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) val moreLibraries = all.toMutableList().apply { removeAll(libraries) } @@ -129,11 +139,19 @@ class NavDrawerViewModel this@NavDrawerViewModel.libraries.value = libraries } val asDestinations = - (libraries + listOf(NavDrawerItem.More) + moreLibraries).map { + ( + libraries + + listOf( + NavDrawerItem.More, + NavDrawerItem.Discover, + ) + moreLibraries + ).map { if (it is ServerNavDrawerItem) { it.destination } else if (it is NavDrawerItem.Favorites) { Destination.Favorites + } else if (it is NavDrawerItem.Discover) { + Destination.Discover } else { null } @@ -156,7 +174,7 @@ class NavDrawerViewModel null } } -// Timber.v("Found $index => $key") + Timber.v("Found $index => $key") if (index != null) { selectedIndex.setValueOnMain(index) break @@ -193,6 +211,13 @@ sealed interface NavDrawerItem { override fun name(context: Context): String = context.getString(R.string.more) } + + object Discover : NavDrawerItem { + override val id: String + get() = "a_discover" + + override fun name(context: Context): String = context.getString(R.string.discover) + } } data class ServerNavDrawerItem( @@ -268,6 +293,13 @@ fun NavDrawer( setShowMore(!showMore) } + NavDrawerItem.Discover -> { + viewModel.setIndex(index) + viewModel.navigationManager.navigateToFromDrawer( + Destination.Discover, + ) + } + is ServerNavDrawerItem -> { viewModel.setIndex(index) viewModel.navigationManager.navigateToFromDrawer(item.destination) @@ -346,10 +378,8 @@ fun NavDrawer( Modifier .fillMaxHeight() .width(drawerWidth) - .background(drawerBackground) - .onFocusChanged { - if (!it.hasFocus) { - } + .drawBehind { + drawRect(drawerBackground) }, ) { // Even though some must be clicked, focusing on it should clear other focused items @@ -609,6 +639,10 @@ fun NavigationDrawerScope.NavItem( R.string.fa_ellipsis } + NavDrawerItem.Discover -> { + R.string.fa_magnifying_glass_plus + } + is ServerNavDrawerItem -> { when (library.type) { CollectionType.MOVIES -> R.string.fa_film diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 0fe5c564..309f099b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -4,8 +4,8 @@ import com.github.damontecres.wholphin.data.model.Chapter import org.jellyfin.sdk.model.api.TrickplayInfo data class CurrentMediaInfo( - val audioStreams: List, - val subtitleStreams: List, + val audioStreams: List, + val subtitleStreams: List, val chapters: List, val trickPlayInfo: TrickplayInfo?, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt new file mode 100644 index 00000000..94a41aa1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/MediaSessionPlayer.kt @@ -0,0 +1,27 @@ +package com.github.damontecres.wholphin.ui.playback + +import androidx.media3.common.ForwardingSimpleBasePlayer +import androidx.media3.common.Player +import com.github.damontecres.wholphin.preferences.PlaybackPreferences +import com.github.damontecres.wholphin.preferences.skipBackOnResume +import com.github.damontecres.wholphin.ui.seekBack +import com.google.common.util.concurrent.ListenableFuture +import timber.log.Timber + +class MediaSessionPlayer( + player: Player, + private val controllerViewState: ControllerViewState, + private val playbackPreferences: PlaybackPreferences, +) : ForwardingSimpleBasePlayer(player) { + override fun handleSetPlayWhenReady(playWhenReady: Boolean): ListenableFuture<*> { + Timber.v("handleSetPlayWhenReady: playWhenReady=$playWhenReady") + if (!playWhenReady && player.isPlaying) { + controllerViewState.showControls() + } else if (playWhenReady) { + playbackPreferences.skipBackOnResume?.let { + player.seekBack(it) + } + } + return super.handleSetPlayWhenReady(playWhenReady) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt deleted file mode 100644 index 728f283a..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/Models.kt +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.WholphinApplication -import org.jellyfin.sdk.model.api.MediaStream - -data class SubtitleStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val external: Boolean, - val forced: Boolean, - val default: Boolean, - val displayTitle: String?, -) { - val displayName: String - get() = - displayTitle ?: listOfNotNull( - language, - title, - codec, - ).joinToString(" - ") - .ifBlank { WholphinApplication.instance.getString(R.string.unknown) } - - companion object { - fun from(it: MediaStream): SubtitleStream = - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } -} - -data class AudioStream( - val index: Int, - val language: String?, - val title: String?, - val codec: String?, - val codecTag: String?, - val channels: Int?, - val channelLayout: String?, -) { - val displayName: String - get() = - listOfNotNull( - language, - title, - codec, - channelLayout?.ifBlank { null } ?: channels?.let { "$it ch" }, - ).joinToString(" - ").ifBlank { "Unknown" } - - companion object { - fun from(it: MediaStream): AudioStream = - AudioStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.channels, - it.channelLayout, - ) - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt index 8cb05699..75ac0951 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackControls.kt @@ -41,7 +41,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource @@ -57,11 +56,13 @@ import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AppColors import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.TextButton import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward @@ -462,12 +463,12 @@ fun PlaybackButton( } @Composable -fun BottomDialog( - choices: List, +fun BottomDialog( + choices: List>, onDismissRequest: () -> Unit, - onSelectChoice: (Int, String) -> Unit, + onSelectChoice: (Int, BottomDialogItem) -> Unit, gravity: Int, - currentChoice: Int? = null, + currentChoice: BottomDialogItem? = null, ) { // TODO enforcing a width ends up ignore the gravity Dialog( @@ -485,7 +486,10 @@ fun BottomDialog( Modifier .wrapContentSize() .padding(8.dp) - .background(Color.DarkGray, shape = RoundedCornerShape(16.dp)), + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp), + shape = RoundedCornerShape(8.dp), + ), ) { LazyColumn( modifier = @@ -498,38 +502,27 @@ fun BottomDialog( ) { itemsIndexed(choices) { index, choice -> val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - val color = - if (focused) { - MaterialTheme.colorScheme.inverseOnSurface - } else { - MaterialTheme.colorScheme.onSurface - } ListItem( - selected = index == currentChoice, + selected = choice == currentChoice, onClick = { onDismissRequest() onSelectChoice(index, choice) }, leadingContent = { - if (index == currentChoice) { - Box( - modifier = - Modifier - .padding(horizontal = 4.dp) - .clip(CircleShape) - .align(Alignment.Center) - .background(color) - .size(8.dp), - ) - } + SelectedLeadingContent(choice == currentChoice) }, headlineContent = { Text( - text = choice, - color = color, + text = choice.headline, ) }, + supportingContent = { + choice.supporting?.let { + Text( + text = it, + ) + } + }, interactionSource = interactionSource, ) } @@ -542,6 +535,12 @@ data class MoreButtonOptions( val options: Map, ) +data class BottomDialogItem( + val data: T, + val headline: String, + val supporting: String?, +) + @PreviewTvSpec @Composable private fun ButtonPreview() { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt index 496166c3..ce31d865 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDebugOverlay.kt @@ -1,11 +1,19 @@ package com.github.damontecres.wholphin.ui.playback +import android.content.Context +import android.hardware.display.DisplayManager +import android.view.Display import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.ProvideTextStyle @@ -14,13 +22,40 @@ import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.ui.byteRateSuffixes import com.github.damontecres.wholphin.ui.formatBytes import com.github.damontecres.wholphin.ui.letNotEmpty +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import org.jellyfin.sdk.model.api.TranscodingInfo +import timber.log.Timber +import java.util.Locale +import kotlin.time.Duration.Companion.seconds @Composable fun PlaybackDebugOverlay( currentPlayback: CurrentPlayback?, modifier: Modifier = Modifier, ) { + val context = LocalContext.current + val display = + remember(context) { + try { + val displayManager = + context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager + displayManager?.getDisplay(Display.DEFAULT_DISPLAY) + } catch (ex: Exception) { + Timber.e(ex) + null + } + } + val displayMode by produceState(null) { + while (isActive) { + value = + display?.mode?.let { + val rate = String.format(Locale.getDefault(), "%.3f", it.refreshRate) + "${it.physicalWidth}x${it.physicalHeight}@${rate}fps, id=${it.modeId}" + } + delay(10.seconds) + } + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, @@ -38,10 +73,12 @@ fun PlaybackDebugOverlay( add("Video Decoder:" to currentPlayback.videoDecoder) add("Audio Decoder:" to currentPlayback.audioDecoder) } + add("Display Mode: " to displayMode?.toString()) }, + modifier = Modifier.weight(1f, fill = false), ) currentPlayback?.transcodeInfo?.let { - TranscodeInfo(it, Modifier) + TranscodeInfo(it, Modifier.weight(2f)) } } } @@ -86,27 +123,21 @@ fun SimpleTable( rows: List>, modifier: Modifier = Modifier, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - rows.forEach { + rows.forEach { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = it.first, + modifier = Modifier.width(100.dp), ) - } - } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier, - ) { - rows.forEach { Text( text = it.second.toString(), + modifier = Modifier, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index f6a42f64..04ff620a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -2,11 +2,20 @@ package com.github.damontecres.wholphin.ui.playback import android.view.Gravity import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalView @@ -15,11 +24,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.indexOfFirstOrNull -import timber.log.Timber +import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import kotlin.time.Duration enum class PlaybackDialogType { @@ -35,17 +47,19 @@ enum class PlaybackDialogType { data class PlaybackSettings( val showDebugInfo: Boolean, val audioIndex: Int?, - val audioStreams: List, + val audioStreams: List, val subtitleIndex: Int?, - val subtitleStreams: List, + val subtitleStreams: List, val playbackSpeed: Float, val contentScale: ContentScale, val subtitleDelay: Duration, + val hasSubtitleDownloadPermission: Boolean, ) @Composable fun PlaybackDialog( enableSubtitleDelay: Boolean, + enableVideoScale: Boolean, type: PlaybackDialogType, settings: PlaybackSettings, onDismissRequest: () -> Unit, @@ -58,7 +72,13 @@ fun PlaybackDialog( PlaybackDialogType.MORE -> { val options = buildList { - add(stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info)) + add( + BottomDialogItem( + data = 0, + headline = stringResource(if (settings.showDebugInfo) R.string.hide_debug_info else R.string.show_debug_info), + supporting = null, + ), + ) } BottomDialog( choices = options, @@ -74,74 +94,85 @@ fun PlaybackDialog( } PlaybackDialogType.CAPTIONS -> { - val subtitleStreams = settings.subtitleStreams - val options = subtitleStreams.map { it.displayName } - Timber.v("subtitleIndex=${settings.subtitleIndex}, options=$options") - val currentChoice = - subtitleStreams.indexOfFirstOrNull { it.index == settings.subtitleIndex } ?: subtitleStreams.size - BottomDialog( - choices = - options + - listOf( - stringResource(R.string.none), - stringResource(R.string.search_and_download), - ), - currentChoice = currentChoice, + SubtitleChoiceBottomDialog( + choices = settings.subtitleStreams, + currentChoice = settings.subtitleIndex, + hasDownloadPermission = settings.hasSubtitleDownloadPermission, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() -// scope.launch { -// // TODO this is hacky, but playback changes force refocus and this is a workaround -// delay(250L) -// captionFocusRequester.tryRequestFocus() -// } }, - onSelectChoice = { index, _ -> - if (index in subtitleStreams.indices) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleStreams[index].index)) - } else { - val idx = index - subtitleStreams.size - if (idx == 0) { - onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) - } else { - onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) - } + onSelectChoice = { subtitleIndex -> + onDismissRequest.invoke() + if (subtitleIndex >= 0) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(subtitleIndex)) + } else if (subtitleIndex == TrackIndex.DISABLED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.DISABLED)) + } else if (subtitleIndex == TrackIndex.ONLY_FORCED) { + onPlaybackActionClick.invoke(PlaybackAction.ToggleCaptions(TrackIndex.ONLY_FORCED)) } }, + onSelectSearch = { + onDismissRequest.invoke() + onPlaybackActionClick.invoke(PlaybackAction.SearchCaptions) + }, gravity = Gravity.END, ) } PlaybackDialogType.SETTINGS -> { + val currentAudio = + remember(settings) { settings.audioStreams.firstOrNull { it.index == settings.audioIndex } } val options = buildList { - add(stringResource(R.string.audio)) - add(stringResource(R.string.playback_speed)) - add(stringResource(R.string.video_scale)) + add( + BottomDialogItem( + data = PlaybackDialogType.AUDIO, + headline = stringResource(R.string.audio), + supporting = currentAudio?.displayTitle, + ), + ) + add( + BottomDialogItem( + data = PlaybackDialogType.PLAYBACK_SPEED, + headline = stringResource(R.string.playback_speed), + supporting = settings.playbackSpeed.toString(), + ), + ) + if (enableVideoScale) { + add( + BottomDialogItem( + data = PlaybackDialogType.VIDEO_SCALE, + headline = stringResource(R.string.video_scale), + supporting = playbackScaleOptions[settings.contentScale], + ), + ) + } if (enableSubtitleDelay) { - add(stringResource(R.string.subtitle_delay)) + add( + BottomDialogItem( + data = PlaybackDialogType.SUBTITLE_DELAY, + headline = stringResource(R.string.subtitle_delay), + supporting = settings.subtitleDelay.toString(), + ), + ) } } BottomDialog( choices = options, currentChoice = null, onDismissRequest = onDismissRequest, - onSelectChoice = { index, _ -> - when (index) { - 0 -> onClickPlaybackDialogType(PlaybackDialogType.AUDIO) - 1 -> onClickPlaybackDialogType(PlaybackDialogType.PLAYBACK_SPEED) - 2 -> onClickPlaybackDialogType(PlaybackDialogType.VIDEO_SCALE) - 3 -> onClickPlaybackDialogType(PlaybackDialogType.SUBTITLE_DELAY) - } + onSelectChoice = { _, choice -> + onClickPlaybackDialogType(choice.data) }, gravity = Gravity.END, ) } PlaybackDialogType.AUDIO -> { - BottomDialog( - choices = settings.audioStreams.map { it.displayName }, - currentChoice = settings.audioStreams.indexOfFirstOrNull { it.index == settings.audioIndex }, + StreamChoiceBottomDialog( + choices = settings.audioStreams, + currentChoice = settings.audioIndex, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -150,17 +181,25 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(settings.audioStreams[index].index)) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.ToggleAudio(choice.index)) }, gravity = Gravity.END, ) } PlaybackDialogType.PLAYBACK_SPEED -> { + val choices = + playbackSpeedOptions.map { + BottomDialogItem( + data = it.toFloat(), + headline = it, + supporting = null, + ) + } BottomDialog( - choices = playbackSpeedOptions, - currentChoice = playbackSpeedOptions.indexOf(settings.playbackSpeed.toString()), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.playbackSpeed }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -170,16 +209,24 @@ fun PlaybackDialog( // } }, onSelectChoice = { _, value -> - onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.toFloat())) + onPlaybackActionClick.invoke(PlaybackAction.PlaybackSpeed(value.data)) }, gravity = Gravity.END, ) } PlaybackDialogType.VIDEO_SCALE -> { + val choices = + playbackScaleOptions.map { (scale, name) -> + BottomDialogItem( + data = scale, + headline = name, + supporting = null, + ) + } BottomDialog( - choices = playbackScaleOptions.values.toList(), - currentChoice = playbackScaleOptions.keys.toList().indexOf(settings.contentScale), + choices = choices, + currentChoice = choices.firstOrNull { it.data == settings.contentScale }, onDismissRequest = { onControllerInteraction.invoke() onDismissRequest.invoke() @@ -188,8 +235,8 @@ fun PlaybackDialog( // settingsFocusRequester.tryRequestFocus() // } }, - onSelectChoice = { index, _ -> - onPlaybackActionClick.invoke(PlaybackAction.Scale(playbackScaleOptions.keys.toList()[index])) + onSelectChoice = { _, choice -> + onPlaybackActionClick.invoke(PlaybackAction.Scale(choice.data)) }, gravity = Gravity.END, ) @@ -222,3 +269,183 @@ fun PlaybackDialog( } } } + +@Composable +fun SubtitleChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int) -> Unit, + onSelectSearch: () -> Unit, + gravity: Int, + hasDownloadPermission: Boolean, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + ListItem( + selected = currentChoice == TrackIndex.DISABLED, + onClick = { + onSelectChoice(TrackIndex.DISABLED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.DISABLED) + }, + headlineContent = { + Text( + text = stringResource(R.string.none), + ) + }, + supportingContent = {}, + ) + } + item { + ListItem( + selected = currentChoice == TrackIndex.ONLY_FORCED, + onClick = { + onSelectChoice(TrackIndex.ONLY_FORCED) + }, + leadingContent = { + SelectedLeadingContent(currentChoice == TrackIndex.ONLY_FORCED) + }, + headlineContent = { + Text( + text = stringResource(R.string.only_forced_subtitles), + ) + }, + supportingContent = {}, + ) + } + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onSelectChoice(choice.index) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + item { + HorizontalDivider() + ListItem( + selected = false, + enabled = hasDownloadPermission, + onClick = onSelectSearch, + leadingContent = {}, + headlineContent = { + Text( + text = stringResource(R.string.search_and_download), + ) + }, + supportingContent = {}, + ) + } + } + } + } +} + +@Composable +fun StreamChoiceBottomDialog( + choices: List, + onDismissRequest: () -> Unit, + onSelectChoice: (Int, SimpleMediaStream) -> Unit, + gravity: Int, + currentChoice: Int? = null, +) { + // TODO enforcing a width ends up ignore the gravity + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = true), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.BOTTOM or gravity) // Move down, by default dialogs are in the centre + window.setDimAmount(0f) // Remove dimmed background of ongoing playback + } + + Box( + modifier = + Modifier + .wrapContentSize() + .padding(8.dp) + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp), + shape = RoundedCornerShape(8.dp), + ), + ) { + LazyColumn( + modifier = + Modifier + .fillMaxWidth() +// .widthIn(max = 240.dp) + .wrapContentWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + itemsIndexed(choices) { index, choice -> + val interactionSource = remember { MutableInteractionSource() } + ListItem( + selected = choice.index == currentChoice, + onClick = { + onDismissRequest() + onSelectChoice(index, choice) + }, + leadingContent = { + SelectedLeadingContent(choice.index == currentChoice) + }, + headlineContent = { + Text( + text = choice.streamTitle ?: choice.displayTitle, + ) + }, + supportingContent = { + if (choice.streamTitle != null) Text(choice.displayTitle) + }, + interactionSource = interactionSource, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index 303de20c..f10a0758 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -45,8 +45,9 @@ class PlaybackKeyHandler( player.seekForward(seekForward) updateSkipIndicator(seekForward.inWholeMilliseconds) } else if (oneClickPause && isEnterKey(it)) { + val wasPlaying = player.isPlaying Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { + if (wasPlaying) { controllerViewState.showControls() } else { skipBackOnResume?.let { @@ -61,27 +62,8 @@ class PlaybackKeyHandler( } } else if (isMedia(it)) { when (it.key) { - Key.MediaPlay -> { - Util.handlePlayButtonAction(player) - skipBackOnResume?.let { - player.seekBack(it) - } - } - - Key.MediaPause -> { - Util.handlePauseButtonAction(player) - controllerViewState.showControls() - } - - Key.MediaPlayPause -> { - Util.handlePlayPauseButtonAction(player) - if (!player.isPlaying) { - controllerViewState.showControls() - } else { - skipBackOnResume?.let { - player.seekBack(it) - } - } + Key.MediaPlay, Key.MediaPause, Key.MediaPlayPause -> { + // no-op, MediaSession will handle } Key.MediaFastForward, Key.MediaSkipForward -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 74821924..99313af2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -19,7 +19,7 @@ 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.sizeIn +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable @@ -457,9 +457,10 @@ fun PlaybackOverlay( AsyncImage( model = logoImageUrl, contentDescription = "Logo", + alignment = Alignment.TopStart, modifier = Modifier - .sizeIn(maxWidth = 180.dp, maxHeight = 100.dp) + .size(width = 240.dp, height = 120.dp) .padding(16.dp), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index 2955b469..0c3c4f2b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity @@ -137,6 +138,7 @@ fun PlaybackPage( val player = viewModel.player val mediaInfo by viewModel.currentMediaInfo.observeAsState() + val userDto by viewModel.currentUserDto.observeAsState() val currentPlayback by viewModel.currentPlayback.collectAsState() val currentItemPlayback by viewModel.currentItemPlayback.observeAsState( @@ -160,7 +162,7 @@ fun PlaybackPage( var playbackDialog by remember { mutableStateOf(null) } OneTimeLaunchedEffect { if (prefs.playerBackend == PlayerBackend.MPV) { - scope.launch(Dispatchers.Main + ExceptionHandler()) { + scope.launch(Dispatchers.IO + ExceptionHandler()) { preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( configuration, density, @@ -169,7 +171,15 @@ fun PlaybackPage( } } AmbientPlayerListener(player) - var contentScale by remember { mutableStateOf(prefs.globalContentScale.scale) } + var contentScale by remember { + mutableStateOf( + if (prefs.playerBackend == PlayerBackend.MPV) { + ContentScale.FillBounds + } else { + prefs.globalContentScale.scale + }, + ) + } var playbackSpeed by remember { mutableFloatStateOf(1.0f) } LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) } @@ -304,15 +314,12 @@ fun PlaybackPage( }, ) if (presentationState.coverSurface) { - val isLoading by rememberPlayerLoadingState(player) Box( Modifier .matchParentSize() .background(Color.Black), ) { - if (isLoading) { - LoadingPage(focusEnabled = false) - } + LoadingPage(focusEnabled = false) } } @@ -521,6 +528,7 @@ fun PlaybackPage( aspectRatio = it.aspectRatio ?: AspectRatios.WIDE, onClick = { viewModel.reportInteraction() + controllerViewState.hideControls() viewModel.playNextUp() }, timeLeft = if (autoPlayEnabled) timeLeft.seconds else null, @@ -589,6 +597,8 @@ fun PlaybackPage( playbackSpeed = playbackSpeed, contentScale = contentScale, subtitleDelay = subtitleDelay, + hasSubtitleDownloadPermission = + remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, ), onDismissRequest = { playbackDialog = null @@ -609,6 +619,7 @@ fun PlaybackPage( onPlaybackActionClick = onPlaybackActionClick, onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) }, enableSubtitleDelay = player is MpvPlayer, + enableVideoScale = player !is MpvPlayer, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4e71a1e8..8d59c1f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -11,6 +11,7 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Tracks @@ -21,8 +22,10 @@ import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.session.MediaSession import coil3.imageLoader import coil3.request.ImageRequest +import coil3.size.Size import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository @@ -44,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.StreamChoiceService +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain @@ -59,6 +63,7 @@ import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener import com.github.damontecres.wholphin.util.checkForSupport import com.github.damontecres.wholphin.util.mpv.mpvDeviceProfile +import com.github.damontecres.wholphin.util.profile.Codec import com.github.damontecres.wholphin.util.subtitleMimeTypes import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.lifecycle.HiltViewModel @@ -137,6 +142,7 @@ class PlaybackViewModel assHandler = creation.assHandler creation.player } + private var mediaSession: MediaSession? = null internal val mutex = Mutex() val controllerViewState = @@ -160,6 +166,7 @@ class PlaybackViewModel internal lateinit var item: BaseItem internal var forceTranscoding: Boolean = false private var activityListener: TrackActivityPlaybackListener? = null + private val jobs = mutableListOf() val nextUp = MutableLiveData() private var isPlaylist = false @@ -168,21 +175,26 @@ class PlaybackViewModel val subtitleSearch = MutableLiveData(null) val subtitleSearchLanguage = MutableLiveData(Locale.current.language) + val currentUserDto = serverRepository.currentUserDto + init { - viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } - player.addListener(this) - (player as? ExoPlayer)?.addAnalyticsListener(this) - addCloseable { player.removeListener(this@PlaybackViewModel) } - addCloseable { (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) } addCloseable { + player.removeListener(this@PlaybackViewModel) + (player as? ExoPlayer)?.removeAnalyticsListener(this@PlaybackViewModel) + this@PlaybackViewModel.activityListener?.let { it.release() player.removeListener(it) } + jobs.forEach { it.cancel() } + player.release() + mediaSession?.release() } - addCloseable { player.release() } - subscribe() - listenForTranscodeReason() + viewModelScope.launch(ExceptionHandler()) { controllerViewState.observe() } + player.addListener(this) + (player as? ExoPlayer)?.addAnalyticsListener(this) + jobs.add(subscribe()) + jobs.add(listenForTranscodeReason()) } /** @@ -233,13 +245,7 @@ class PlaybackViewModel "Error preparing for playback for $itemId", ), ) { - val destItem = (destination as? Destination.Playback)?.item?.data - val queriedItem = - if (destItem?.mediaSources != null) { - destItem - } else { - api.userLibraryApi.getItem(itemId).content - } + val queriedItem = api.userLibraryApi.getItem(itemId).content val base = if (queriedItem.type.playable) { queriedItem @@ -277,6 +283,18 @@ class PlaybackViewModel } else { throw IllegalArgumentException("Item is not playable and not PlaybackList: ${queriedItem.type}") } + + val sessionPlayer = + MediaSessionPlayer( + player, + controllerViewState, + preferences.appPreferences.playbackPreferences, + ) + mediaSession = + MediaSession + .Builder(context, sessionPlayer) + .build() + val item = BaseItem.from(base, api) val played = @@ -318,12 +336,15 @@ class PlaybackViewModel withContext(Dispatchers.IO) { Timber.i("Playing ${item.id}") - // Starting playback, so want to invalidate the last played timestamp for this item - datePlayedService.invalidate(item) // New item, so we can clear the media segment tracker & subtitle cues - autoSkippedSegments.clear() + resetSegmentState() this@PlaybackViewModel.subtitleCues.setValueOnMain(listOf()) + viewModelScope.launchIO { + // Starting playback, so want to invalidate the last played timestamp for this item + datePlayedService.invalidate(item) + } + if (item.type !in supportItemKinds) { showToast( context, @@ -366,15 +387,18 @@ class PlaybackViewModel val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map(SubtitleStream::from) - .orEmpty() + ?.map { + SimpleMediaStream.from(context, it, true) + }.orEmpty() + val audioStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.AUDIO } - ?.map(AudioStream::from) - ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) + ?.map { + SimpleMediaStream.from(context, it, true) + } +// ?.sortedWith(compareBy { it.language }.thenByDescending { it.channels }) .orEmpty() - val audioStream = streamChoiceService .chooseAudioStream( @@ -450,7 +474,7 @@ class PlaybackViewModel player.prepare() player.play() } - listenForSegments() + listenForSegments(item.id) return@withContext true } @@ -484,8 +508,9 @@ class PlaybackViewModel if (externalSubtitle == null) { val result = withContext(Dispatchers.Main) { - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + onMain { player.trackSelectionParameters }, + onMain { player.currentTracks }, playerBackend, true, audioIndex, @@ -494,13 +519,20 @@ class PlaybackViewModel ) } if (result.bothSelected) { + onMain { player.trackSelectionParameters = result.trackSelectionParameters } // TODO lots of duplicate code in this block Timber.d("Changes tracks audio=$audioIndex, subtitle=$subtitleIndex") val itemPlayback = currentItemPlayback.copy( sourceId = source.id?.toUUIDOrNull(), audioIndex = audioIndex ?: TrackIndex.UNSPECIFIED, - subtitleIndex = subtitleIndex ?: TrackIndex.DISABLED, + // Preserve special constants (ONLY_FORCED, DISABLED) instead of resolved index + subtitleIndex = + if (currentItemPlayback.subtitleIndex < 0) { + currentItemPlayback.subtitleIndex + } else { + subtitleIndex ?: TrackIndex.DISABLED + }, ) if (userInitiated) { viewModelScope.launchIO { @@ -573,13 +605,18 @@ class PlaybackViewModel source?.let { source -> val mediaUrl = if (source.supportsDirectPlay) { - api.videosApi.getVideoStreamUrl( - itemId = itemId, - mediaSourceId = source.id, - static = true, - tag = source.eTag, - playSessionId = response.playSessionId, - ) + if (source.isRemote && source.path.isNotNullOrBlank()) { + Timber.i("Playback is remote for source: %s", source.id) + source.path + } else { + api.videosApi.getVideoStreamUrl( + itemId = itemId, + mediaSourceId = source.id, + static = true, + tag = source.eTag, + playSessionId = response.playSessionId, + ) + } } else if (source.supportsDirectStream) { source.transcodingUrl?.let(api::createUrl) } else { @@ -632,7 +669,12 @@ class PlaybackViewModel .setMediaId(itemId.toString()) .setUri(mediaUrl.toUri()) .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) - .build() + .apply { + when (source.container) { + Codec.Container.HLS -> setMimeType(MimeTypes.APPLICATION_M3U8) + Codec.Container.DASH -> setMimeType(MimeTypes.APPLICATION_MPD) + } + }.build() val playback = CurrentPlayback( @@ -645,10 +687,16 @@ class PlaybackViewModel mediaSourceInfo = source, ) - if (preferences.appPreferences.playbackPreferences.refreshRateSwitching) { - source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO }?.let { - refreshRateService.changeRefreshRate(it) - } + preferences.appPreferences.playbackPreferences.let { prefs -> + source.mediaStreams + ?.firstOrNull { it.type == MediaStreamType.VIDEO } + ?.let { stream -> + refreshRateService.changeRefreshRate( + stream = stream, + switchRefreshRate = prefs.refreshRateSwitching, + switchResolution = prefs.resolutionSwitching, + ) + } } withContext(Dispatchers.Main) { // TODO, don't need to release & recreate when switching streams @@ -680,8 +728,9 @@ class PlaybackViewModel Timber.v("onTracksChanged: $tracks") if (tracks.groups.isNotEmpty()) { val result = - TrackSelectionUtils.applyTrackSelections( - player, + TrackSelectionUtils.createTrackSelections( + player.trackSelectionParameters, + player.currentTracks, playerBackend, source.supportsDirectPlay, audioIndex.takeIf { transcodeType == PlayMethod.DIRECT_PLAY }, @@ -689,6 +738,8 @@ class PlaybackViewModel source, ) if (result.bothSelected) { + player.trackSelectionParameters = + result.trackSelectionParameters player.removeListener(this) } viewModelScope.launchIO { loadSubtitleDelay() } @@ -712,11 +763,27 @@ class PlaybackViewModel type = MediaStreamType.AUDIO, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track based on new audio language + val source = currentPlayback.value?.mediaSourceInfo + val resolvedSubtitleIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = index, + seriesId = item.data.seriesId, + subtitleIndex = itemPlayback.subtitleIndex, + prefs = preferences, + ) + } else { + itemPlayback.subtitleIndex.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, index, - itemPlayback.subtitleIndex, + resolvedSubtitleIndex, onMain { player.currentPosition }, true, ) @@ -734,11 +801,27 @@ class PlaybackViewModel type = MediaStreamType.SUBTITLE, ) this@PlaybackViewModel.currentItemPlayback.setValueOnMain(itemPlayback) + + // Resolve ONLY_FORCED to actual track index for playback + val source = currentPlayback.value?.mediaSourceInfo + val resolvedIndex = + if (source != null) { + streamChoiceService.resolveSubtitleIndex( + source = source, + audioStreamIndex = itemPlayback.audioIndex, + seriesId = item.data.seriesId, + subtitleIndex = index, + prefs = preferences, + ) + } else { + index.takeIf { it >= 0 } + } + changeStreams( item, itemPlayback, itemPlayback.audioIndex, - index, + resolvedIndex, onMain { player.currentPosition }, true, ) @@ -758,7 +841,7 @@ class PlaybackViewModel ImageRequest .Builder(context) .data(url) - .size(coil3.size.Size.ORIGINAL) + .size(Size.ORIGINAL) .build(), ) } @@ -796,10 +879,19 @@ class PlaybackViewModel private var segmentJob: Job? = null + /** + * Cancels listening for segments and clears current segment state + */ + private suspend fun resetSegmentState() { + segmentJob?.cancel() + autoSkippedSegments.clear() + currentSegment.setValueOnMain(null) + } + /** * This sets up a coroutine to periodically check whether the current playback progress is within a media segment (intro, outro, etc) */ - private fun listenForSegments() { + private fun listenForSegments(itemId: UUID) { segmentJob?.cancel() segmentJob = viewModelScope.launchIO { @@ -815,7 +907,10 @@ class PlaybackViewModel .firstOrNull { it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } - if (currentSegment != null && autoSkippedSegments.add(currentSegment.id)) { + if (currentSegment != null && + currentSegment.itemId == this@PlaybackViewModel.itemId && + autoSkippedSegments.add(currentSegment.id) + ) { Timber.d( "Found media segment for %s: %s, %s", currentSegment.itemId, @@ -871,7 +966,7 @@ class PlaybackViewModel } } - private fun listenForTranscodeReason() { + private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { currentPlayback.collectLatest { if (it != null) { @@ -902,7 +997,6 @@ class PlaybackViewModel } } } - } private var lastInteractionDate: Date = Date() @@ -1023,11 +1117,14 @@ class PlaybackViewModel } fun release() { + Timber.v("release") activityListener?.release() player.release() + mediaSession?.release() + activityListener = null } - fun subscribe() { + fun subscribe(): Job = api.webSocket .subscribe() .onEach { message -> @@ -1082,7 +1179,6 @@ class PlaybackViewModel } } }.launchIn(viewModelScope) - } /** * Atomically update [currentMediaInfo] diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt new file mode 100644 index 00000000..885be814 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SimpleMediaStream.kt @@ -0,0 +1,25 @@ +package com.github.damontecres.wholphin.ui.playback + +import android.content.Context +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.util.StreamFormatting.mediaStreamDisplayTitle +import org.jellyfin.sdk.model.api.MediaStream + +data class SimpleMediaStream( + val index: Int, + val streamTitle: String?, + val displayTitle: String, +) { + companion object { + fun from( + context: Context, + mediaStream: MediaStream, + includeFlags: Boolean = true, + ): SimpleMediaStream = + SimpleMediaStream( + index = mediaStream.index, + streamTitle = mediaStream.title?.takeIf { it.isNotNullOrBlank() }, + displayTitle = mediaStreamDisplayTitle(context, mediaStream, includeFlags), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt index 8568b286..021f4290 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SubtitleSearchUtils.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.extensions.subtitleApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.RemoteSubtitleInfo import timber.log.Timber @@ -81,16 +82,20 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( itemId = it.sourceId ?: it.itemId, subtitleId = subtitleId, ) + val currentSource = + this@downloadAndSwitchSubtitles.currentPlayback.value?.mediaSourceInfo val currentSubtitleStreams = - this@downloadAndSwitchSubtitles - .currentMediaInfo.value - ?.subtitleStreams + currentSource + ?.mediaStreams + ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() + val externalPaths = currentSubtitleStreams.map { it.path } + val subtitleCount = currentSubtitleStreams.size var newCount = subtitleCount var maxAttempts = 4 - var newStreams: List = listOf() + var mediaSource: MediaSourceInfo? = null // The server triggers a refresh in the background, so query periodically for the item until its updated while (maxAttempts > 0 && subtitleCount == newCount) { maxAttempts-- @@ -100,7 +105,7 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( api.userLibraryApi.getItem(itemId = it.itemId).content, api, ) - val mediaSource = streamChoiceService.chooseSource(item.data, it) + mediaSource = streamChoiceService.chooseSource(item.data, it) if (mediaSource == null) { // This shouldn't happen, but just in case showToast( @@ -116,26 +121,6 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ?.filter { it.type == MediaStreamType.SUBTITLE } .orEmpty() newCount = subtitleStreams.size - - if (subtitleCount != newCount) { - newStreams = - subtitleStreams.map { - SubtitleStream( - it.index, - it.language, - it.title, - it.codec, - it.codecTag, - it.isExternal, - it.isForced, - it.isDefault, - it.displayTitle, - ) - } - updateCurrentMedia { - it.copy(subtitleStreams = newStreams) - } - } } if (maxAttempts == 0) { showToast( @@ -144,12 +129,12 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( ) } else { // Find the new subtitle stream + val subtitlesStreams = + mediaSource?.mediaStreams?.filter { it.type == MediaStreamType.SUBTITLE } val newStream = - newStreams - .toMutableList() - .apply { - removeAll(currentSubtitleStreams) - }.firstOrNull { it.external } + subtitlesStreams?.firstOrNull { stream -> + stream.isExternal && stream.path !in externalPaths + } if (newStream != null) { var audioIndex = currentItemPlayback.value?.audioIndex if (audioIndex != null && audioIndex != TrackIndex.UNSPECIFIED) { @@ -158,6 +143,14 @@ fun PlaybackViewModel.downloadAndSwitchSubtitles( Timber.v("New external subtitle, audioIndex=$audioIndex, adding 1") audioIndex += 1 } + updateCurrentMedia { + it.copy( + subtitleStreams = + subtitlesStreams.map { + SimpleMediaStream.from(context, it, true) + }, + ) + } this@downloadAndSwitchSubtitles.changeStreams( item, currentItemPlayback.value!!, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt index 44a44e1e..4c952a20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/TrackSelectionUtils.kt @@ -3,8 +3,9 @@ package com.github.damontecres.wholphin.ui.playback import androidx.annotation.OptIn import androidx.media3.common.C import androidx.media3.common.Format -import androidx.media3.common.Player import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.preferences.PlayerBackend import org.jellyfin.sdk.model.api.MediaSourceInfo @@ -12,24 +13,24 @@ import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import timber.log.Timber +import kotlin.math.max object TrackSelectionUtils { @OptIn(UnstableApi::class) - fun applyTrackSelections( - player: Player, + fun createTrackSelections( + trackSelectionParams: TrackSelectionParameters, + tracks: Tracks, playerBackend: PlayerBackend, supportsDirectPlay: Boolean, audioIndex: Int?, subtitleIndex: Int?, source: MediaSourceInfo, ): TrackSelectionResult { - val videoStreamCount = source.videoStreamCount - val audioStreamCount = source.audioStreamCount val embeddedSubtitleCount = source.embeddedSubtitleCount val externalSubtitleCount = source.externalSubtitlesCount - val paramsBuilder = player.trackSelectionParameters.buildUpon() - val tracks = player.currentTracks.groups + val paramsBuilder = trackSelectionParams.buildUpon() + val groups = tracks.groups val subtitleSelected = if (subtitleIndex != null && subtitleIndex >= 0) { @@ -37,7 +38,7 @@ object TrackSelectionUtils { if (subtitleIsExternal || supportsDirectPlay) { val chosenTrack = if (subtitleIsExternal && playerBackend == PlayerBackend.EXO_PLAYER) { - tracks.firstOrNull { group -> + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + group.type == C.TRACK_TYPE_TEXT && + (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_TEXT && group.isSupported && (0.. + groups.firstOrNull { group -> group.type == C.TRACK_TYPE_AUDIO && group.isSupported && (0.. { - serverIndex - externalSubtitleCount - videoStreamCount + 1 + val videoStreamsBeforeAudioCount = + source.mediaStreams + .orEmpty() + .indexOfFirst { it.type == MediaStreamType.AUDIO } - externalSubtitleCount + serverIndex - externalSubtitleCount - videoStreamsBeforeAudioCount + 1 } MediaStreamType.SUBTITLE -> { if (subtitleIsExternal) { - serverIndex + embeddedSubtitleCount + 1 + // Need to account for the actual embedded count because if the library + // disables embedded subtitles, they still exist in the direct played file, + // but not included in the MediaStreams list + serverIndex + max(actualEmbeddedCount ?: 0, embeddedSubtitleCount) + 1 } else { + val videoStreamCount = source.videoStreamCount + val audioStreamCount = source.audioStreamCount serverIndex - externalSubtitleCount - videoStreamCount - audioStreamCount + 1 } } @@ -228,12 +250,14 @@ fun MediaSourceInfo.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = me fun List.findExternalSubtitle(subtitleIndex: Int?): MediaStream? = subtitleIndex?.let { firstOrNull { - it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && + it.type == MediaStreamType.SUBTITLE && + (it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL || it.isExternal) && it.index == subtitleIndex } } data class TrackSelectionResult( + val trackSelectionParameters: TrackSelectionParameters, val audioSelected: Boolean, val subtitleSelected: Boolean, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 71eac49f..95432283 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableIntStateOf @@ -42,6 +43,7 @@ import androidx.tv.material3.surfaceColorAtElevation import coil3.SingletonImageLoader import coil3.imageLoader import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend @@ -50,6 +52,7 @@ import com.github.damontecres.wholphin.preferences.basicPreferences import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.UpdateChecker +import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination @@ -58,9 +61,12 @@ import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel +import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog +import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch import timber.log.Timber @@ -71,6 +77,7 @@ fun PreferencesContent( modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), updateVM: UpdateViewModel = hiltViewModel(), + seerrVm: SwitchSeerrViewModel = hiltViewModel(), onFocus: (Int, Int) -> Unit = { _, _ -> }, ) { val context = LocalContext.current @@ -84,6 +91,8 @@ fun PreferencesContent( val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } + val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -381,6 +390,29 @@ fun PreferencesContent( ) } + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (seerrIntegrationEnabled) { + seerrDialogMode = SeerrDialogMode.Remove + } else { + seerrVm.resetStatus() + seerrDialogMode = SeerrDialogMode.Add + } + }, + modifier = Modifier, + summary = + if (seerrIntegrationEnabled) { + stringResource(R.string.enabled) + } else { + null + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -440,6 +472,45 @@ fun PreferencesContent( ) } } + when (seerrDialogMode) { + SeerrDialogMode.Remove -> { + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = "", + onCancel = { seerrDialogMode = SeerrDialogMode.None }, + onConfirm = { + seerrVm.removeServer() + seerrDialogMode = SeerrDialogMode.None + }, + ) + } + + SeerrDialogMode.Add -> { + val currentUser by seerrVm.currentUser.observeAsState() + val status by seerrVm.serverConnectionStatus.collectAsState(LoadingState.Pending) + val serverAddedMessage = stringResource(R.string.seerr_server_added) + LaunchedEffect(status) { + if (status == LoadingState.Success) { + Toast.makeText(context, serverAddedMessage, Toast.LENGTH_SHORT).show() + seerrDialogMode = SeerrDialogMode.None + } + } + AddSeerServerDialog( + currentUsername = currentUser?.name, + status = status, + onSubmit = { url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod -> + if (method == SeerrAuthMethod.API_KEY) { + seerrVm.submitServer(url, passwordOrApiKey) + } else { + seerrVm.submitServer(url, username ?: "", passwordOrApiKey, method) + } + }, + onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, + ) + } + + SeerrDialogMode.None -> {} + } } } @@ -481,3 +552,11 @@ data class CacheUsage( val imageMemoryMax: Long, val imageDiskUsed: Long, ) + +private sealed class SeerrDialogMode { + data object None : SeerrDialogMode() + + data object Add : SeerrDialogMode() + + data object Remove : SeerrDialogMode() +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index d1e2baa2..9973bbec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao @@ -17,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem @@ -25,6 +27,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo @@ -43,6 +46,7 @@ class PreferencesViewModel private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), @@ -52,6 +56,11 @@ class PreferencesViewModel val currentUser get() = serverRepository.currentUser + val seerrEnabled = + seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> + seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt new file mode 100644 index 00000000..0c74f715 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -0,0 +1,306 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun AddSeerrServerApiKey( + onSubmit: (url: String, apiKey: String) -> Unit, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var apiKey by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = "Enter URL & API Key", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "URL", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = "API Key", + modifier = Modifier.padding(end = 8.dp), + ) + EditTextBox( + value = apiKey, + onValueChange = { + error = null + apiKey = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, apiKey) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, apiKey) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && apiKey.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@Composable +fun AddSeerrServerUsername( + onSubmit: (url: String, username: String, password: String) -> Unit, + username: String, + status: LoadingState, + modifier: Modifier = Modifier, +) { + var error by remember(status) { mutableStateOf((status as? LoadingState.Error)?.localizedMessage) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .focusGroup() + .padding(16.dp) + .wrapContentSize(), + ) { + var url by remember { mutableStateOf("") } + var username by remember { mutableStateOf(username) } + var password by remember { mutableStateOf("") } + + val focusRequester = remember { FocusRequester() } + val usernameFocusRequester = remember { FocusRequester() } + val passwordFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + Text( + text = stringResource(R.string.username_or_password), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + val labelWidth = 90.dp + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.url), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = url, + onValueChange = { + error = null + url = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + usernameFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.username), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = username, + onValueChange = { + error = null + username = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Next, + ), + keyboardActions = + KeyboardActions( + onNext = { + passwordFocusRequester.tryRequestFocus() + }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(focusRequester), + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = stringResource(R.string.password), + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), + ) + EditTextBox( + value = password, + onValueChange = { + error = null + password = it + }, + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + keyboardActions = + KeyboardActions( + onGo = { onSubmit.invoke(url, username, password) }, + ), + isInputValid = { true }, + modifier = Modifier.focusRequester(passwordFocusRequester), + ) + } + error?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.titleLarge, + ) + } + TextButton( + stringRes = R.string.submit, + onClick = { onSubmit.invoke(url, username, password) }, + enabled = error.isNullOrBlank() && url.isNotNullOrBlank() && username.isNotNullOrBlank() && password.isNotNullOrBlank(), + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } +} + +@PreviewTvSpec +@Composable +private fun AddSeerrServerUsernamePreview() { + WholphinTheme { + AddSeerrServerUsername( + onSubmit = { string: String, string1: String, string2: String -> }, + username = "test", + status = LoadingState.Pending, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt new file mode 100644 index 00000000..0feef21c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.util.LoadingState + +@Composable +fun AddSeerServerDialog( + currentUsername: String?, + status: LoadingState, + onSubmit: (url: String, username: String?, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onDismissRequest: () -> Unit, +) { + var authMethod by remember { mutableStateOf(null) } + LaunchedEffect(status) { + if (status is LoadingState.Success) { + onDismissRequest.invoke() + } + } + when (val auth = authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerUsername( + onSubmit = { url, username, password -> + onSubmit.invoke(url, username, password, auth) + }, + username = currentUsername ?: "", + status = status, + ) + } + } + + SeerrAuthMethod.API_KEY -> { + BasicDialog( + onDismissRequest = { authMethod = null }, + ) { + AddSeerrServerApiKey( + onSubmit = { url, apiKey -> + onSubmit.invoke(url, null, apiKey, SeerrAuthMethod.API_KEY) + }, + status = status, + ) + } + } + + null -> { + ChooseSeerrLoginType( + onDismissRequest = onDismissRequest, + onChoose = { authMethod = it }, + ) + } + } +} + +@Composable +fun ChooseSeerrLoginType( + onDismissRequest: () -> Unit, + onChoose: (SeerrAuthMethod) -> Unit, +) { + val params = + remember { + DialogParams( + fromLongClick = false, + title = "Login to Seerr server", + items = + listOf( + DialogItem( + text = "API Key", + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, + ), + DialogItem( + text = "Jellyfin user", + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = "Local user", + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + } + DialogPopup( + params = params, + onDismissRequest = onDismissRequest, + dismissOnClick = false, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt new file mode 100644 index 00000000..0b067287 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.ui.setup.seerr + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.SeerrAuthMethod +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import jakarta.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import timber.log.Timber + +@HiltViewModel +class SwitchSeerrViewModel + @Inject + constructor( + private val seerrServerRepository: SeerrServerRepository, + private val seerrService: SeerrService, + private val serverRepository: ServerRepository, + ) : ViewModel() { + val currentUser = serverRepository.currentUser + + val serverConnectionStatus = MutableStateFlow(LoadingState.Pending) + + private fun cleanUrl(url: String) = + if (!url.endsWith("/api/v1")) { + url + .toHttpUrlOrNull() + ?.newBuilder() + ?.apply { + addPathSegment("api") + addPathSegment("v1") + }?.build() + .toString() + } else { + url + } + + fun submitServer( + url: String, + apiKey: String, + ) { + viewModelScope.launchIO { + val url = cleanUrl(url) + val result = + try { + seerrServerRepository.testConnection( + authMethod = SeerrAuthMethod.API_KEY, + url = url, + username = null, + passwordOrApiKey = apiKey, + ) + } catch (ex: ClientException) { + Timber.w(ex, "Error logging in via API Key") + if (ex.statusCode == 401 || ex.statusCode == 403) { + LoadingState.Error("Invalid credentials", ex) + } else { + LoadingState.Error(ex) + } + } + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, apiKey) + } + serverConnectionStatus.update { result } + } + } + + fun submitServer( + url: String, + username: String, + password: String, + authMethod: SeerrAuthMethod, + ) { + viewModelScope.launchIO { + val url = cleanUrl(url) + val result = + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url, + username = username, + passwordOrApiKey = password, + ) + } catch (ex: ClientException) { + Timber.w(ex, "Error logging in via %s", authMethod) + if (ex.statusCode == 401 || ex.statusCode == 403) { + LoadingState.Error("Invalid credentials", ex) + } else { + LoadingState.Error(ex) + } + } + if (result is LoadingState.Success) { + seerrServerRepository.addAndChangeServer(url, authMethod, username, password) + } + serverConnectionStatus.update { result } + } + } + + fun removeServer() { + viewModelScope.launchIO { + seerrServerRepository.removeServer() + } + } + + fun resetStatus() { + serverConnectionStatus.update { LoadingState.Pending } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt index dfae73c5..9cae3698 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/ThemePreview.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawerScope @@ -30,10 +31,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.ui.AspectRatios -import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.cards.WatchedIcon -import com.github.damontecres.wholphin.ui.components.ExpandableFaButton import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavItem import com.github.damontecres.wholphin.ui.playback.PlaybackButton @@ -43,7 +42,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf @Preview( - device = "spec:width=1200dp,height=2000dp", + device = "spec:width=1200dp,height=2500dp", backgroundColor = 0xFF383535, uiMode = UI_MODE_TYPE_TELEVISION, ) @@ -109,6 +108,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 50.0, + numberOfVersions = 2, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -125,6 +125,7 @@ private fun ThemeExample(theme: AppThemeColors) { isPlayed = true, unplayedItemCount = 2, playedPercentage = 0.0, + numberOfVersions = 0, onClick = { }, onLongClick = {}, imageHeight = 120.dp, @@ -270,6 +271,24 @@ private fun ThemeExample(theme: AppThemeColors) { interactionSource = source, ) } + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.surface), + ) { + ListItem( + selected = false, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + ListItem( + selected = true, + enabled = true, + headlineContent = { Text("Headline content") }, + supportingContent = { Text("Support content") }, + onClick = {}, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt index 813aaf79..68583c4c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/PurpleThemeColors.kt @@ -41,7 +41,7 @@ val PurpleThemeColors = val secondaryDark = Color(0xFFD2BCFF) val onSecondaryDark = Color(0xFF3B167D) val secondaryContainerDark = Color(0xFF48415B) - val onSecondaryContainerDark = Color(0xFFC2A6FF) + val onSecondaryContainerDark = Color(0xFFDFD3F8) val tertiaryDark = Color(0xFFA071F8) val onTertiaryDark = Color(0xFF5E0052) val tertiaryContainerDark = Color(0xFFB800A3) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt index 53cd4d9b..ec634ab3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/LocalClock.kt @@ -3,17 +3,16 @@ package com.github.damontecres.wholphin.ui.util import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import com.github.damontecres.wholphin.ui.TimeFormatter import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import java.time.LocalDateTime -val LocalClock = compositionLocalOf { throw IllegalStateException() } +val LocalClock = compositionLocalOf { Clock() } /** * Represents the current time @@ -22,20 +21,23 @@ data class Clock( /** * The current [LocalDateTime] */ - val now: LocalDateTime, + val now: MutableState = mutableStateOf(LocalDateTime.now()), /** * The current time formatted as a string with [TimeFormatter] */ - val timeString: String, + val timeString: MutableState = mutableStateOf(TimeFormatter.format(now.value)), ) @Composable fun ProvideLocalClock(content: @Composable () -> Unit) { - var clock by remember { mutableStateOf(LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) }) } + val clock = remember { Clock() } LaunchedEffect(Unit) { while (isActive) { - clock = LocalDateTime.now().let { Clock(it, TimeFormatter.format(it)) } - delay(1_000) + val now = LocalDateTime.now() + val time = TimeFormatter.format(now) + clock.now.value = now + clock.timeString.value = time + delay(2_000) } } CompositionLocalProvider(LocalClock provides clock, content) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt new file mode 100644 index 00000000..ff4e62da --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -0,0 +1,171 @@ +package com.github.damontecres.wholphin.ui.util + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.util.languageName +import com.github.damontecres.wholphin.util.profile.Codec +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.VideoRange +import org.jellyfin.sdk.model.api.VideoRangeType + +/** + * Collection of utility functions for formatting the display of media streams + */ +object StreamFormatting { + fun interlaced(interlaced: Boolean) = if (interlaced) "i" else "p" + + // Adapted from https://github.com/jellyfin/jellyfin/blob/aa4ddd139a7c01889a99561fc314121ba198dd70/MediaBrowser.Model/Entities/MediaStream.cs#L714 + fun resolutionString( + width: Int, + height: Int, + interlaced: Boolean, + ): String = + if (height > width) { + // Vertical video + resolutionString(height, width, interlaced) + } else { + when { + width <= 256 && height <= 144 -> "144" + interlaced(interlaced) + width <= 426 && height <= 240 -> "240" + interlaced(interlaced) + width <= 640 && height <= 360 -> "360" + interlaced(interlaced) + width <= 682 && height <= 384 -> "384" + interlaced(interlaced) + width <= 720 && height <= 404 -> "404" + interlaced(interlaced) + width <= 854 && height <= 480 -> "480" + interlaced(interlaced) + width <= 960 && height <= 544 -> "540" + interlaced(interlaced) + width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) + width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) + width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) + width <= 4096 && height <= 3072 -> "4K" + width <= 8192 && height <= 6144 -> "8K" + else -> height.toString() + interlaced(interlaced) + } + } + + fun formatVideoRange( + context: Context, + videoRange: VideoRange?, + type: VideoRangeType?, + doviTitle: String?, + ): String? = + when (videoRange) { + VideoRange.UNKNOWN, + VideoRange.SDR, null, + -> { + null + } + + VideoRange.HDR -> { + if (doviTitle.isNotNullOrBlank()) { + context.getString(R.string.dolby_vision) + } else { + when (type) { + VideoRangeType.UNKNOWN, + VideoRangeType.SDR, + null, + -> null + + VideoRangeType.HDR10 -> "HDR10" + + VideoRangeType.HDR10_PLUS -> "HDR10+" + + VideoRangeType.HLG -> "HLG" + + VideoRangeType.DOVI, + VideoRangeType.DOVI_WITH_HDR10, + VideoRangeType.DOVI_WITH_HLG, + VideoRangeType.DOVI_WITH_SDR, + -> context.getString(R.string.dolby_vision) + } + } + } + } + + fun formatAudioCodec( + context: Context, + codec: String?, + profile: String?, + ): String? = + when { + profile?.contains("Dolby Atmos", true) == true -> { + context.getString(R.string.dolby_atmos) + } + + profile?.contains("DTS:X", true) == true -> { + context.getString(R.string.dts_x) + } + + profile?.contains("DTS:HD", true) == true -> { + context.getString(R.string.dts_hd) + } + + else -> { + when (codec?.lowercase()) { + Codec.Audio.TRUEHD -> context.getString(R.string.truehd) + + Codec.Audio.AC3 -> context.getString(R.string.dolby_digital) + + Codec.Audio.EAC3 -> context.getString(R.string.dolby_digital_plus) + + Codec.Audio.DCA -> context.getString(R.string.dts) + + Codec.Audio.OGG, + Codec.Audio.OPUS, + Codec.Audio.VORBIS, + -> codec.replaceFirstChar { it.uppercase() } + + null -> null + + else -> codec.uppercase() + } + } + } + + fun formatSubtitleCodec(codec: String?): String? = + when (codec?.lowercase()) { + Codec.Subtitle.DVBSUB -> "DVB" + Codec.Subtitle.DVDSUB -> "DVD" + Codec.Subtitle.PGSSUB -> "PGS" + Codec.Subtitle.SUBRIP -> "SRT" + null -> null + else -> codec.uppercase() + } + + fun String?.concatWithSpace(str: String?): String? = + when { + this != null && str != null -> "$this $str" + this == null -> str + else -> this + } + + fun mediaStreamDisplayTitle( + context: Context, + stream: MediaStream, + includeFlags: Boolean, + ): String { + val name = + buildList { + add(languageName(stream.language)) + if (stream.type == MediaStreamType.AUDIO) { + add(formatAudioCodec(context, stream.codec, stream.profile)) + add(stream.channelLayout) + } else if (stream.type == MediaStreamType.SUBTITLE) { + "SDH".takeIf { stream.isHearingImpaired }?.let(::add) + add(formatSubtitleCodec(stream.codec)) + } + }.joinToString(" ") + if (includeFlags) { + val flags = + buildList { + if (stream.isDefault) add(stream.localizedDefault) + if (stream.isForced) add(stream.localizedForced) + if (stream.isExternal) add(stream.localizedExternal) + }.joinToString(", ") + if (flags.isNotEmpty()) { + return "$name ($flags)" + } + } + return name + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 9bf07d62..c35e9512 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -4,6 +4,8 @@ import com.github.damontecres.wholphin.data.model.BaseItem /** * Generic state for loading something from the API + * + * @see DataLoadingState */ sealed interface LoadingState { data object Pending : LoadingState @@ -68,3 +70,28 @@ sealed interface HomeRowLoadingState { listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") } } + +/** + * Generic state for loading something from the API + * + * @see LoadingState + */ +sealed interface DataLoadingState { + data object Pending : DataLoadingState + + data object Loading : DataLoadingState + + data class Success( + val data: T, + ) : DataLoadingState + + data class Error( + val message: String? = null, + val exception: Throwable? = null, + ) : DataLoadingState { + constructor(exception: Throwable) : this(null, exception) + + val localizedMessage: String = + listOfNotNull(message, exception?.localizedMessage).joinToString(" - ") + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt new file mode 100644 index 00000000..89b900f0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LocalDateSerializer.kt @@ -0,0 +1,24 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +object LocalDateSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = SerialDescriptor("LocalDate", String.serializer().descriptor) + + override fun serialize( + encoder: Encoder, + value: LocalDate, + ) { + encoder.encodeString(DateTimeFormatter.ISO_LOCAL_DATE.format(value)) + } + + override fun deserialize(decoder: Decoder): LocalDate = + decoder.decodeString().let { LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt index acb2f8d8..e5b902d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MPVLib.kt @@ -24,6 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import android.content.Context import android.graphics.Bitmap import android.view.Surface +import java.util.concurrent.CopyOnWriteArrayList // Wrapper for native library @@ -96,20 +97,16 @@ object MPVLib { format: Int, ) - private val observers = mutableListOf() + private val observers = CopyOnWriteArrayList() @JvmStatic fun addObserver(o: EventObserver) { - synchronized(observers) { - observers.add(o) - } + observers.add(o) } @JvmStatic fun removeObserver(o: EventObserver) { - synchronized(observers) { - observers.remove(o) - } + observers.remove(o) } @JvmStatic @@ -117,10 +114,8 @@ object MPVLib { property: String, value: Long, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -129,10 +124,8 @@ object MPVLib { property: String, value: Boolean, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -141,10 +134,8 @@ object MPVLib { property: String, value: Double, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @@ -153,28 +144,22 @@ object MPVLib { property: String, value: String, ) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property, value) - } + for (o in observers) { + o.eventProperty(property, value) } } @JvmStatic fun eventProperty(property: String) { - synchronized(observers) { - for (o in observers) { - o.eventProperty(property) - } + for (o in observers) { + o.eventProperty(property) } } @JvmStatic fun event(eventId: Int) { - synchronized(observers) { - for (o in observers) { - o.event(eventId) - } + for (o in observers) { + o.event(eventId) } } @@ -183,27 +168,21 @@ object MPVLib { reason: Int, error: Int, ) { - synchronized(observers) { - for (o in observers) { - o.eventEndFile(reason, error) - } + for (o in observers) { + o.eventEndFile(reason, error) } } - private val log_observers = mutableListOf() + private val log_observers = CopyOnWriteArrayList() @JvmStatic fun addLogObserver(o: LogObserver) { - synchronized(log_observers) { - log_observers.add(o) - } + log_observers.add(o) } @JvmStatic fun removeLogObserver(o: LogObserver) { - synchronized(log_observers) { - log_observers.remove(o) - } + log_observers.remove(o) } @JvmStatic @@ -212,10 +191,8 @@ object MPVLib { level: Int, text: String, ) { - synchronized(log_observers) { - for (o in log_observers) { - o.logMessage(prefix, level, text) - } + for (o in log_observers) { + o.logMessage(prefix, level, text) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt index 4777e977..58c976c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/mpv/MpvPlayer.kt @@ -53,6 +53,7 @@ import kotlin.concurrent.atomics.AtomicReference import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.update import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -69,7 +70,8 @@ class MpvPlayer( ) : BasePlayer(), MPVLib.EventObserver, TrackSelector.InvalidationListener, - Handler.Callback { + Handler.Callback, + SurfaceHolder.Callback { companion object { private const val DEBUG = false } @@ -109,6 +111,7 @@ class MpvPlayer( Timber.v("config-dir=${context.filesDir.path}") MPVLib.addLogObserver(mpvLogger) + Timber.v("Creating MPVLib") MPVLib.create(context) MPVLib.setOptionString("config", "yes") MPVLib.setOptionString("config-dir", context.filesDir.path) @@ -127,6 +130,7 @@ class MpvPlayer( MPVLib.setOptionString("demuxer-max-bytes", "${cacheMegs * 1024 * 1024}") MPVLib.setOptionString("demuxer-max-back-bytes", "${cacheMegs * 1024 * 1024}") + Timber.v("Initializing MPVLib") MPVLib.initialize() MPVLib.setOptionString("force-window", "no") @@ -228,11 +232,6 @@ class MpvPlayer( override fun prepare() { if (DEBUG) Timber.v("prepare") - playbackState.update { - it.copy( - state = Player.STATE_READY, - ) - } } override fun getPlaybackState(): Int { @@ -243,7 +242,8 @@ class MpvPlayer( override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE override fun getPlayerError(): PlaybackException? { - TODO("Not yet implemented") + // TODO + return null } override fun setPlayWhenReady(playWhenReady: Boolean) { @@ -391,8 +391,7 @@ class MpvPlayer( override fun getCurrentTimeline(): Timeline { if (DEBUG) Timber.v("getCurrentTimeline") - // TODO - return Timeline.EMPTY + return playbackState.load().timeline } override fun getCurrentPeriodIndex(): Int { @@ -465,27 +464,55 @@ class MpvPlayer( override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?): Unit = throw UnsupportedOperationException() + private var surfaceHolder: SurfaceHolder? = null + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { - throwIfReleased() if (DEBUG) Timber.v("setVideoSurfaceView") - val surface = surfaceView?.holder?.surface - if (surface != null && surface.isValid) { - Timber.v("Queued attach") - sendCommand(MpvCommand.ATTACH_SURFACE, surface) - } else { - clearVideoSurfaceView(null) + if (surfaceView != null) { + this.surfaceHolder?.removeCallback(this) + this.surfaceHolder = surfaceView.holder + if (surfaceView.holder != null) { + val surface = surfaceView.holder?.surface + surfaceView.holder.addCallback(this) + Timber.v("Got surface holder: isValid=${surface?.isValid}") + if (surface != null && surface.isValid) { + Timber.v("Queued attach") + sendCommand(MpvCommand.ATTACH_SURFACE, surface) + return + } + } } + clearVideoSurfaceView(null) } override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { - if (surface == surfaceView?.holder?.surface) { + if (surface != null && surface == surfaceView?.holder?.surface) { Timber.d("clearVideoSurfaceView") sendCommand(MpvCommand.ATTACH_SURFACE, null) } else { - Timber.w("clearVideoSurfaceView called with different surface") + Timber.w("clearVideoSurfaceView called with different surface: %s", surfaceView) } } + override fun surfaceChanged( + holder: SurfaceHolder, + format: Int, + width: Int, + height: Int, + ) { + Timber.v("surfaceChanged: format=$format, width=$width, height=$height") + } + + override fun surfaceCreated(holder: SurfaceHolder) { + Timber.v("surfaceCreated") + sendCommand(MpvCommand.ATTACH_SURFACE, holder.surface) + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + Timber.v("surfaceDestroyed") + sendCommand(MpvCommand.ATTACH_SURFACE, null) + } + override fun setVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() override fun clearVideoTextureView(textureView: TextureView?): Unit = throw UnsupportedOperationException() @@ -496,7 +523,7 @@ class MpvPlayer( return playbackState.load().videoSize } - override fun getSurfaceSize(): Size = throw UnsupportedOperationException() + override fun getSurfaceSize(): Size = surfaceHolder?.surfaceFrame?.let { Size(it.width(), it.height()) } ?: Size.UNKNOWN override fun getCurrentCues(): CueGroup = CueGroup.EMPTY_TIME_ZERO @@ -623,21 +650,24 @@ class MpvPlayer( } MPV_EVENT_FILE_LOADED -> { - playbackState.update { - it.copy(isLoadingFile = false) - } - notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } Timber.d("event: MPV_EVENT_FILE_LOADED") - internalHandler.post(updatePlaybackState) + playbackState.update { + it.copy( + isLoadingFile = false, + ) + } + updatePlaybackState.run() + notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(false) } + playbackState.load().media?.mediaItem?.let { media -> media.localConfiguration?.subtitleConfigurations?.forEach { val url = it.uri.toString() val title = it.label ?: "External Subtitles" Timber.v("Adding external subtitle track '$title'") if (it.language.isNotNullOrBlank()) { - MPVLib.command(arrayOf("sub-add", url, "auto", title, it.language!!)) + MPVLib.command(arrayOf("sub-add", url, "select", title, it.language!!)) } else { - MPVLib.command(arrayOf("sub-add", url, "auto", title)) + MPVLib.command(arrayOf("sub-add", url, "select", title)) } } } @@ -659,6 +689,7 @@ class MpvPlayer( MPV_EVENT_VIDEO_RECONFIG -> { Timber.d("event: MPV_EVENT_VIDEO_RECONFIG") updateTracksAndNotify() + updateVideoSizeAndNotify() } MPV_EVENT_END_FILE -> { @@ -719,12 +750,78 @@ class MpvPlayer( notifyListeners(EVENT_TRACKS_CHANGED) { onTracksChanged(tracks) } } + private fun updateVideoSizeAndNotify() { + val width = MPVLib.getPropertyInt("width") + val height = MPVLib.getPropertyInt("height") + val videoSize = + if (width != null && height != null) { + VideoSize(width, height) + } else { + VideoSize.UNKNOWN + } + playbackState.update { it.copy(videoSize = videoSize) } + notifyListeners(EVENT_VIDEO_SIZE_CHANGED) { onVideoSizeChanged(videoSize) } + } + private fun loadFile(media: MediaAndPosition) { Timber.v("loadFile: media=$media") + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + media.mediaItem.mediaId, + media.mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + true, + media.mediaItem.liveConfiguration, + 0L, + C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + media.mediaItem.mediaId, + media.mediaItem.mediaId, + 0, + C.TIME_UNSET, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = media.mediaItem.mediaId + } playbackState.update { it.copy( isLoadingFile = true, + state = STATE_READY, media = media, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, ) } notifyListeners(EVENT_IS_LOADING_CHANGED) { onIsLoadingChanged(true) } @@ -792,7 +889,8 @@ class MpvPlayer( private val updatePlaybackState: Runnable = Runnable { - if (playbackState.load().media == null) { + val state = playbackState.load() + if (state.media == null) { return@Runnable } val positionMs = @@ -815,6 +913,53 @@ class MpvPlayer( VideoSize.UNKNOWN } + val mediaItem = state.media.mediaItem + val timeline = + object : Timeline() { + override fun getWindowCount(): Int = 1 + + override fun getWindow( + windowIndex: Int, + window: Window, + defaultPositionProjectionUs: Long, + ): Window = + window.set( + mediaItem.mediaId, + mediaItem, + null, + C.TIME_UNSET, + C.TIME_UNSET, + C.TIME_UNSET, + true, + false, + mediaItem.liveConfiguration, + 0L, + if (durationMs != C.TIME_UNSET) durationMs.milliseconds.inWholeMicroseconds else C.TIME_UNSET, + 0, + 0, + 0, + ) + + override fun getPeriodCount(): Int = 1 + + override fun getPeriod( + periodIndex: Int, + period: Period, + setIds: Boolean, + ): Period = + period.set( + mediaItem.mediaId, + mediaItem.mediaId, + 0, + state.durationMs.milliseconds.inWholeMicroseconds, + 0, + ) + + override fun getIndexOfPeriod(uid: Any): Int = 0 + + override fun getUidOfPeriod(periodIndex: Int) = mediaItem.mediaId + } + playbackState.update { it.copy( timestamp = System.currentTimeMillis(), @@ -824,6 +969,13 @@ class MpvPlayer( speed = speed, isPaused = paused, videoSize = videoSize, + timeline = timeline, + ) + } + notifyListeners(EVENT_TIMELINE_CHANGED) { + onTimelineChanged( + timeline, + TIMELINE_CHANGE_REASON_SOURCE_UPDATE, ) } } @@ -835,12 +987,34 @@ class MpvPlayer( internalHandler.obtainMessage(cmd.ordinal, obj).sendToTarget() } + private val queuedCommands = mutableListOf>() + override fun handleMessage(msg: Message): Boolean { val cmd = MpvCommand.entries[msg.what] - Timber.v("handleMessage: cmd=$cmd") + if (isReleased && cmd != MpvCommand.DESTROY) { + Timber.w("Player is released, ignoring command %s", cmd) + return true + } + if (surface == null && !cmd.isLifecycle) { + // If libmpv isn't ready, ueue the messages + // Note: this means nothing will play until it is attached to a surface, + // so MpvPlayer can't be used for background audio/music playback + Timber.v("MPV is not initialized/attached yet, queue cmd %s", cmd) + queuedCommands.add(Pair(cmd, msg.obj)) + } else { + handleCommand(cmd, msg.obj) + } + return true + } + + private fun handleCommand( + cmd: MpvCommand, + obj: Any?, + ) { + Timber.d("handleCommand: cmd=$cmd") when (cmd) { MpvCommand.PLAY_PAUSE -> { - val playWhenReady = msg.obj as Boolean + val playWhenReady = obj as Boolean MPVLib.setPropertyBoolean("pause", !playWhenReady) playbackState.update { it.copy(isPaused = !playWhenReady) @@ -854,13 +1028,13 @@ class MpvPlayer( } MpvCommand.SET_TRACK_SELECTION -> { - val (propertyName, trackId) = msg.obj as TrackSelection + val (propertyName, trackId) = obj as TrackSelection MPVLib.setPropertyString(propertyName, trackId) updateTracksAndNotify() } MpvCommand.SEEK -> { - val positionMs = msg.obj as Long + val positionMs = obj as Long MPVLib.setPropertyDouble("time-pos", positionMs / 1000.0) playbackState.update { it.copy(positionMs = positionMs) @@ -868,7 +1042,7 @@ class MpvPlayer( } MpvCommand.SET_SPEED -> { - val value = msg.obj as Float + val value = obj as Float MPVLib.setPropertyDouble("speed", value.toDouble()) playbackState.update { it.copy(speed = value) @@ -876,7 +1050,7 @@ class MpvPlayer( } MpvCommand.SET_SUBTITLE_DELAY -> { - val value = msg.obj as Double + val value = obj as Double MPVLib.setPropertyDouble("sub-delay", value) playbackState.update { it.copy(subtitleDelay = value) @@ -884,22 +1058,30 @@ class MpvPlayer( } MpvCommand.LOAD_FILE -> { - loadFile(msg.obj as MediaAndPosition) + loadFile(obj as MediaAndPosition) } MpvCommand.ATTACH_SURFACE -> { - val surface = msg.obj as Surface? + val surface = obj as Surface? if (surface == null || (this.surface != null && this.surface != surface)) { // If clearing or changing the surface MPVLib.detachSurface() MPVLib.setPropertyString("vo", "null") MPVLib.setPropertyString("force-window", "no") + Timber.d("Detached surface") } if (surface != null) { MPVLib.attachSurface(surface) this.surface = surface MPVLib.setOptionString("force-window", "yes") Timber.d("Attached surface") + if (queuedCommands.isNotEmpty()) { + Timber.d("Processing queued commands") + while (queuedCommands.isNotEmpty()) { + val msg = queuedCommands.removeAt(0) + handleCommand(msg.first, msg.second) + } + } } } @@ -914,7 +1096,6 @@ class MpvPlayer( Timber.d("MPVLib destroyed") } } - return true } } @@ -1005,6 +1186,7 @@ private data class PlaybackState( val videoSize: VideoSize, @param:Player.State val state: Int, val tracks: Tracks, + val timeline: Timeline, ) { companion object { val EMPTY = @@ -1021,6 +1203,7 @@ private data class PlaybackState( videoSize = VideoSize.UNKNOWN, state = Player.STATE_IDLE, subtitleDelay = 0.0, + timeline = Timeline.EMPTY, ) } } @@ -1035,14 +1218,16 @@ private data class MediaAndPosition( val startPositionMs: Long, ) -enum class MpvCommand { - PLAY_PAUSE, - SEEK, - SET_TRACK_SELECTION, - SET_SPEED, - SET_SUBTITLE_DELAY, - LOAD_FILE, - ATTACH_SURFACE, - INITIALIZE, - DESTROY, +enum class MpvCommand( + val isLifecycle: Boolean, +) { + PLAY_PAUSE(false), + SEEK(false), + SET_TRACK_SELECTION(false), + SET_SPEED(false), + SET_SUBTITLE_DELAY(false), + LOAD_FILE(false), + ATTACH_SURFACE(true), + INITIALIZE(true), + DESTROY(true), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt index 03318879..ad661617 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/Codec.kt @@ -7,6 +7,7 @@ object Codec { const val `3GP` = "3gp" const val ASF = "asf" const val AVI = "avi" + const val DASH = "dash" const val DVR_MS = "dvr-ms" const val HLS = "hls" const val M2V = "m2v" diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index a143d860..b98ececd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -66,6 +66,8 @@ fun createDeviceProfile( downMixAudio: Boolean, assDirectPlay: Boolean, pgsDirectPlay: Boolean, + dolbyVisionELDirectPlay: Boolean, + decodeAv1: Boolean, jellyfinTenEleven: Boolean, ) = buildDeviceProfile { val allowedAudioCodecs = @@ -156,6 +158,7 @@ fun createDeviceProfile( container( Codec.Container.ASF, + Codec.Container.DASH, Codec.Container.HLS, Codec.Container.M4V, Codec.Container.MKV, @@ -210,7 +213,7 @@ fun createDeviceProfile( "main", "baseline", "constrained baseline", - if (supportsAVCHigh10) "main 10" else null, + if (supportsAVCHigh10) "high 10" else null, ) } } @@ -336,6 +339,7 @@ fun createDeviceProfile( conditions { when { + decodeAv1 -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" !supportsAV1 -> ProfileConditionValue.VIDEO_PROFILE equals "none" !supportsAV1Main10 -> ProfileConditionValue.VIDEO_PROFILE notEquals "main 10" else -> ProfileConditionValue.VIDEO_PROFILE notEquals "none" @@ -380,13 +384,15 @@ fun createDeviceProfile( } // AV1 - codecProfile { - type = CodecType.VIDEO - codec = Codec.Video.AV1 + if (!decodeAv1) { + codecProfile { + type = CodecType.VIDEO + codec = Codec.Video.AV1 - conditions { - ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width - ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + conditions { + ProfileConditionValue.WIDTH lowerThanOrEquals maxResolutionAV1.width + ProfileConditionValue.HEIGHT lowerThanOrEquals maxResolutionAV1.height + } } } @@ -408,16 +414,18 @@ fun createDeviceProfile( buildSet { if (jellyfinTenEleven) add("DOVIInvalid") - if (!supportsAV1DolbyVision) { - add(VideoRangeType.DOVI.serialName) - if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) - if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") - } + if (!decodeAv1) { + if (!supportsAV1DolbyVision) { + add(VideoRangeType.DOVI.serialName) + if (!supportsAV1HDR10) add(VideoRangeType.DOVI_WITH_HDR10.serialName) + if (jellyfinTenEleven && !supportsAV1HDR10Plus) add("DOVIWithHDR10Plus") + } - if (!supportsAV1HDR10Plus) { - add(VideoRangeType.HDR10_PLUS.serialName) + if (!supportsAV1HDR10Plus) { + add(VideoRangeType.HDR10_PLUS.serialName) - if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + if (!mediaTest.supportsAV1HDR10()) add(VideoRangeType.HDR10.serialName) + } } } @@ -427,9 +435,11 @@ fun createDeviceProfile( if (jellyfinTenEleven) add("DOVIInvalid") if (!supportsHevcDolbyVisionEL) { - if (jellyfinTenEleven) { - add("DOVIWithEL") - if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + if (!dolbyVisionELDirectPlay) { + if (jellyfinTenEleven) { + add("DOVIWithEL") + if (!supportsHevcHDR10Plus && !KnownDefects.hevcDoviHdr10PlusBug) add("DOVIWithELHDR10Plus") + } } if (!supportsHevcDolbyVision) { diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index f10b49c5..0710b51d 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -45,6 +45,8 @@ message PlaybackOverrides{ bool direct_play_ass = 3; bool direct_play_pgs = 4; MediaExtensionStatus media_extensions_enabled = 5; + bool direct_play_dolby_vision_e_l = 6; + bool decode_av1 = 7; } message PlaybackPreferences { @@ -72,6 +74,7 @@ message PlaybackPreferences { PlayerBackend player_backend = 20; MpvOptions mpv_options = 21; bool refresh_rate_switching = 22; + bool resolution_switching = 23; } message HomePagePreferences{ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 13f608d7..23dca91e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -239,7 +239,7 @@ Nützlich zum Debuggen Sende Absturzmeldung Zurückspringen wenn wiedergabe fortgesetzt wird - Überspringe Werbung verhalten + Werbung überspringen Vorspringen Überspringe Intro verhalten Überspringe Outro verhalten @@ -286,4 +286,39 @@ Entfernen Dolby Vision Dolby Atmos + Endet um %1$s + Server Adresse eingeben + Keine Server gefunden + Medieninformation + Abgespielt + MPV: Benutze gpu-next + mpv.conf bearbeiten + Änderungen verwerfen? + Abstand + PIN Eingeben + Automatisch anmelden + Profil benötigt PIN + PIN bestätigen + Falsch + PIN muss 4 Zeichen oder länger sein + PIN wird entfernt + Anzeigeoptionen + Spalten + Abstände + Seitenverhältnis + Details anzeigen + Bild Typ + Automatisch + Verwende Benutzername/Passwort + Anmelden + Titel + Profil + Auflösung + Bildrate + Sprache + Kanäle + Ja + Nein + Titel anzeigen + Wiederholen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f1ca329f..ce48243d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,8 +1,8 @@ - Favorito - Agregar servidor - Agregar usuario + Añadir a Favoritos + Añadir servidor + Añadir usuario Audio Lugar de nacimiento Tasa de bits @@ -170,8 +170,8 @@ Canciones principales - - + + Vídeos temáticos @@ -303,4 +303,76 @@ Fuente Fondo Clasificación parental + Termina en %1$s + Introduzca la dirección del servidor + No se encontraron servidores + Información multimedia + MPV: Usar gpu-next + Editar mpv.conf + ¿Descartar los cambios? + Margen + Registro detallado + Introducir PIN + Iniciar sesión automáticamente + Iniciar sesión a través del servidor + Pulsa el centro para confirmar + Requerir PIN para el perfil + Confirmar PIN + Incorrecto + El PIN debe tener 4 teclas o más + Se eliminará el PIN + Tamaño de la caché de disco de imágenes (MB) + Opciones de visualización + Columnas + Espaciado + Relación de aspecto + Mostrar detalles + Tipo de imagen + + Estrellas invitadas + Tamaño del borde + Cambio de frecuencia de actualización + Automático + Usar nombre de usuario/contraseña + Iniciar sesión + General + Contenedor + Título + Codec + Perfil + Nivel + Resolución + Anamórfico + Entrelazado + Frecuencia de fotogramas + Profundidad de bits + Rango de vídeo + Tipo de rango de vídeo + Espacio de color + Transferencia de color + Primarias de color + Formato de píxel + Fotogramas de referencia + NAL + Idioma + Distribución + Canales + Frecuencia de muestreo + AVC + + No + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostrar títulos + Repetir + Mostrar primero los canales favoritos + Ordenar los canales por vistos recientemente + Programas codificados por colores + Retardo de subtítulos + Estilo del fondo diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 59aa1f7b..4809541d 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -358,4 +358,5 @@ Sisesta serveri aadress Ühtegi serverit ei leidu Tausta dekoratsioon + Lõppeb kell %1$s diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ba3fb676..539ca50c 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -370,4 +370,9 @@ Images de référence Trier les chaînes par date de visionnage Classer les programmes par code couleur + Terminera à %1$s + Entrez l\'adresse du serveur + Aucun serveur trouvé + Décalage des sous-titres + Style d\'arrière-plan diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 7d0320bb..e9c73c87 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,36 +1,36 @@ Névjegy - Kedvenc + Hozzáadás kedvencekhez Szerver hozzáadása Felhasználó hozzáadása - Audio + Hang Születési hely Bitráta Született Mégse Fejezetek - Válassz %1$s - Kép gyorsítótár törlése + %1$s kiválasztása + Képgyorsítótár törlése Gyűjtemény Gyűjtemények Közösségi értékelés - Hiba történt! Nyomd meg a gombot, hogy elküldd a naplófájlokat a szervernek. + Hiba történt! Nyomja meg a gombot, hogy elküldje a naplófájlokat a szervernek. Megerősít Kritikusi értékelés %.1f másodperc Alapértelmezett Törlés - Rendezte %1$s + Rendezte: %1$s Rendező Letiltva Felfedezett szerverek Letöltés folyamatban… Engedélyezve - Add meg a szerver IP-t vagy URL-t + Adja meg a szerver IP-t vagy URL-t Epizódok - Hiba a gyűjtemény betöltése során: %1$s + Hiba a következő gyűjtemény betöltése során: %1$s Külső Kedvencek Aktív felvételek @@ -38,28 +38,28 @@ Sorozatfelvétel leállítása Reklám Megtekintés folytatása - Halott + Elhunyt Méret - Kényszerített + Kiegészítő Műfajok Ugrás a sorozathoz - Ugrás + Ugrás ide: Lejátszási gombok elrejtése - Hibajavítási infó elrejtése + Hibakeresési infó elrejtése Elrejt Kezdőlap Azonnal - Intro + Intró #ABCDEFGHIJKLMNOPQRSTUVWXYZ Könyvtár Lincenc információk Élő TV Betöltés… - Megjelölöd az egész sorozatot megnézettként? - Megjelölöd az egész sorozatot megnézetlenként? + Megjelöli az egész sorozatot megnézettként? + Megjelöli az egész sorozatot megnézetlenként? Jelölés megnézetlenként Jelölés megnézettként - Max bitráta + Maximális bitráta Hasonlóak Továbbiak Filmek @@ -70,15 +70,15 @@ Nincs időzített felvétel Nincs elérhető frissítés Nincs - Outro + Stáblista Elérési út - Emberek + Stábtagok Lejátszások száma Lejátszás innen Lejátszás Lejátszás hibakeresési adatainak mutatása Lejátszási sebesség - Visszajátszás + Lejátszás Lejátszási lista Lejátszási listák Előnézet @@ -87,9 +87,9 @@ Nemrég hozzáadva: %1$s Nemrég hozzáadott Nemrég rögzített - Nemrég kiadott + Nemrég megjelent Ajánlott - Program felvétele + Műsor felvétele Sorozat felvétele Törlés a kedvencekből Újraindítás @@ -100,117 +100,263 @@ Keresés… Szerver kiválasztása Felhasználó kiválasztása - Hibakeresési info megjelenítése + Hibakeresési infó megjelenítése Következő mutatása Megmutat Véletlenszerű Átugrás - Hozzáadva + Hozzáadás dátuma Epizód hozzáadásának dátuma Lejátszás dátuma Megjelenés dátuma Név - Random + Véletlenszerűen Stúdiók Beküld - A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítanod a lejátszást + A letöltés sok időt vesz igénybe, előfordulhat, hogy újra kell indítani a lejátszást Felirat Feliratok Ajánlások - Váltás szerverek között + Váltás másik szerverre Váltás - Legjobbra értékelt nem megtekintett + Legjobbra értékelt, nem megnézett Előzetes Előzetesek - + DVR Időzítés Műsorújság Évad Évadok Sorozatok - Felület + Kezelőfelület Ismeretlen Frissítések Verzió - Képarány + Kép méretezése Videó Videók - Nézd élőben + Nézze élőben %1$d éves Lejátszás átalakítással Aktuális idő mutatása - Sorrend epízód vetítése alapján + Sorrend epizód vetítése alapján Új lejátszási lista létrehozása Hozzáadás lejátszási listához Szüneteltetés egy klikkel - Nyomd a D-Pad közepét megállításhoz/lejátszáshoz + Nyomja le a D-Pad közepét megállításhoz/lejátszáshoz Dőlt betűk - Betűtípus - Háttérszín + Betűk stílusa + Háttér Sikeres - Szülői besorolás - Műsoridő + Korhatár-besorolás + Hossz Extrák Egyéb - + Egyebek Színfalak mögött - + - Téma zenék - + Főcímdal + Főcímdalok - Téma videók - + Téma videó + Téma videók - Klippek - + Klip + Klipek - Törölt jelenetek - + Törölt jelenet + Törölt jelenetek - Interjúk - + Interjú + Interjúk - Jelenetek - + Jelenet + Jelenetek - Minták - + Minta + Minták - Rövidfilmek - + Rövidfilm + Rövidfilmek - Rövidek - + Rövid + Rövidek %s letöltés - %s letöltések + %s letöltés %d óra - %d órák + %d óra %d elem - %d elemek + %d elem %d másodperc - %d másodpercek + %d másodperc + %1$s-kor ér véget + Nem található szerver + Összefoglaló + Médiainformáció + A készülék támogatja az AC3/Dolby Digital formátumot + Haladó beállítások + Haladó kezelőfelület-beállítások + Alkalmazás témája + Frissítések automatikus keresése + Késleltetés a következő elem lejátszása előtt + Következő elem automatikus lejátszása + Frissítések keresése + Csak sorozatok esetén + ASS feliratok közvetlen lejátszása + PGS feliratok közvetlen lejátszása + Mindig konvertálja sztereóvá + FFmpeg dekóder modul használata + Tartalom alapértelmezett méretezése + Frissítés telepítése + Telepített verzió + Maximális elemek soronként a kezdőlapon + Válassza ki az alapértelmezett elemeket, amelyek megjelennek, a többi el lesz rejtve + Navigációs menü elemeinek testreszabása + Kattintson az oldalak közti váltáshoz + Automatikus leállítás inaktivitás esetén + Főcímdal lejátszása + Kiválasztott lapok megjegyzése + Újranézés engedélyezése a Következő menüben + Előre/hátra ugrás intervallum + Hibakereséshez hasznos + Naplófájlok küldése a jelenlegi szerverre + Megpróbálja elküldeni az legutóbb csatlakozott szerverre + Összeomlási napló küldése + Beállítások + Visszaugrás a lejátszás folytatásakor + Visszaugrás + Reklám-átugrás viselkedés + Előreugrás + Intró-átugrás viselkedés + Stáblista-átugrás viselkedés + Előzetes-átugrás viselkedés + Összefoglaló-átugrás viselkedés + Frissítés érhető el + Az alkalmazás frissítéseinek ellenőrzésére használt URL + Frissítés URL + Betűméret + Betűszín + Betűk áttetszősége + Szegély stílusa + Szegély színe + Háttér áttetszősége + Háttér típusa + Felirat stílusa + Háttér színe + Visszaállítás + Félkövér betűk + MPV: hardveres dekódolás használata + Tiltsa le, ha összeomlást tapasztal + MPV beállítások + ExoPlayer beállítások + Szegmens átugrása + Reklám átugrása + Előzetes átugrása + Összefoglaló átugrása + Stáblista átugrása + Intró átugrása + Lejátszott + Szűrő + Év + Évtized + Eltávolítás + Dolby Vision + Dolby Atmos + MPV: gpu-next használata + mpv.conf szerkesztése + Elveti a változtatásokat? + Függőleges eltolás + Részletes naplózás + Adja meg a PIN-kódot + Automatikus bejelentkezés + Bejelentkezés szerveren keresztül + Nyomja meg a középső gombot a megerősítéshez + Kérjen PIN-kódot a profillal történő bejelentkezéshez + PIN-kód megerősítése + Helytelen + A PIN-kódnak legalább 4 karakterből kell állnia + PIN-kód eltávolítása + Nézetbeállítások + Oszlopok + Helyköz + Képarány + Részletek mutatása + Kép típusa + Vendégszereplők + Szegély mérete + Tartalomhoz igazodó képfrissítési gyakoriság + Automatikus + Felhasználónév/jelszó használata + Bejelentkezés + Általános + Konténer + Cím + Kodek + Profil + Szint + Felbontás + Anamorfikus + Váltottsoros + Képfrissítés + Színmélység + Feketeszint tartomány + Feketeszint tartomány típusa + Színtér + Színátviteli jellemző + Alapszínek + Pixelformátum + Referencia-képkockák + NAL + Nyelv + Elrendezés + Csatornák + Mintavételezés + AVC + Igen + Nem + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Címek mutatása + Ismétlés + A kedvenc csatornákat mutassa először + Legutóbb nézett csatornák előre sorolása + Műsorok kategória szerinti színezése + Felirat késleltetése + Háttér stílusa + Adja meg a szerver címét + + Lejátszás backend + Kép gyorsítótár méret (MB) + + Navigációs menü oldalainak váltása fókusznál + Lejátszás felülbírálatai diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index fc1f6866..456bee92 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -332,4 +332,8 @@ Resolusi Sampel rate Jarak + Berakhir pada %1$s + Masukkan alamat server + Server tidak ditemukan + Penundaan subtitel diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index faf958b2..c8837cdf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -371,4 +371,8 @@ Ordina canali per ultimo guardato Codifica colori per i programmi Ritardo sottotitoli + Inserisci indirizzo server + Nessun server trovato + Stile backdrop + Termina alle %1$s diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 045e125f..51d5978a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,3 +1,33 @@ + אודות + הקלטות פעילות + הוספה למועדפים + הוסף שרת + הוסף משתמש + שמע + מקום לידה + קצב נתונים + בטל הקלטה + בטל הקלטת סדרה + בטל + פרקים + נקה זיכרון מטמון + אוסף + אוספים + מסחרי + דירוג קהילתי + אירעה שגיאה! לחץ על הכפתור על מנת לשלוח יומני אירועים לשרת שלך. + לאשר + המשך צפייה + דירוג מבקרים + %.1f שניות + ברירת מחדל + מחק + מת + בבימויו של %1$s + במאי + מושבת + נולד + בחר %1$s diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml new file mode 100644 index 00000000..43c74c5a --- /dev/null +++ b/app/src/main/res/values-kab/strings.xml @@ -0,0 +1,46 @@ + + + Ɣef + Imesli + Adig n tlalit + Aktum + Semmet + Ixfawen + Tagrumma + Tigrummiwin + Serggeg + Amezwer + Kkes + Yemmut + Ffer-it + Tazwara + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Tamkarḍit + Ales asekker + Kemmel + Sekles + Nadi + Yettnadi… + Sken-d + Isem + Agacuṛan + Lqem + Tavidyut + Tividyutin + Tasefsit + Agilal + Yedda + Aseggas + Awurman + Tuqqna + Amatu + Amagbar + Azwel + Akudak + Amaɣnu + Aswir + HDR10 + HDR10+ + HLG + Fren %1$s + diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index fd8c6cce..b4826c5b 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -3,12 +3,12 @@ Om Aktive opptak Favoritt - Legg til Server - Legg til Brukar + Legg til server + Legg til brukar Lyd Fødestad Bitrate - Født + Fødd Avbryt opptak Avbryt opptak av serie Avbryt @@ -21,4 +21,342 @@ Slett Dødd Regissert av %1$s + Eininga støttar AC3/Dolby Digital + Tøm biletmellomlager + Reklame + Brukarvurdering + Ein feil har oppstått! Trykk på knappen for å sende loggar til servaren din. + Hald fram med å sjå + Meldevurdering + %.1f sekund + Regissør + Deaktivert + Funne servarar + + Lastar ned… + Aktivert + Oppgi servar-IP eller URL + Oppgi servaradresse + Episodar + Feil ved lasting av samling %1$s + Ekstern + Favorittar + Storleik + Tvungen + Sjangrar + Gå til serie + Gå til + Gøym avspelingskontrollar + Gøym feilsøkingsinfo + Gøym + Heim + Omgåande + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Lisensinformasjon + Direkte-TV + Lastar… + Marker heile serien som sett? + Marker heile serien som usett? + Marker som usett + Marker som sett + Maks bitrate + Meir som dette + Meir + Filmar + Namn + Neste + Ingen data + Ingen resultat + Ingen servarar funne + Ingen planlagde opptak + Ingen oppdatering tilgjengeleg + Outro + Filveg + Folk + Antall avspelingar + Spel av herfrå + Spel av + Vis feilsøkingsinfo for avspeling + Avspelingsfart + Avspeling + Speleliste + Spelelister + Førehandsvising + Innstillingar for brukarprofil + + Samandrag + Sist lagt til i %1$s + Sist lagt til + Sist tatt opp + Sist utgitt + Tilrådd + Ta opp program + Ta opp serie + Fjern favoritt + Start på nytt + Hald fram + Lagre + + Søk + Søker… + Vel servar + Vel brukar + Vis feilsøkingsinfo + Vis \'Neste\' + Serie + Tilfeldig rekkjefølgje + Hopp over + Dato lagt til + Dato episode lagt til + Dato spela av + Utginningsdato + Namn + Tilfeldig + Studio + Send inn + Undertekst + Undertekstar + Forslag + Byt servar + Byt brukar + Best vurderte usette + Trailer + + Trailerar + + + DVR-plan + Programoversikt + Sesong + Sesongar + TV-seriar + Grensesnitt + Ukjend + Oppdateringar + Versjon + Videoskalering + Video + Videoar + Sjå direkte + %1$d år gammal + Spel av med transkoding + Mediainformasjon + Vis klokke + Rekkjefølgje etter sendedato + Lag ny speleliste + Legg til i speleliste + Pause med eitt klikk + Trykk i midten på styreputa for å pause/spele av + Kursiv skrift + Skrifttype + Bakgrunn + Fullført + Aldersgrense + Speltid + Ekstra + + Anna + + + + Bak kulissane + + + + Temasongar + + + + Temavideoar + + + + Klipp + + + + Sletta sener + + + + Intervju + + + + Scener + + + + Smakebitar + + + + Bakomfilmar + + + + Kortfilmar + + + Avanserte innstillingar + Avansert grensesnitt + App-tema + Sjekk automatisk etter oppdateringar + Forseinking før neste vert spela av + Spel neste automatisk + Sjekk etter oppdateringar + Gjeld berre TV-seriar + Slå saman \'Hald fram med å sjå\' og \'Neste\' + Direkteavspeling av ASS-undertekstar + Direkteavspeling av PGS-undertekstar + Alltid nedmiks til stereo + Bruk FFmpeg-dekodermodul + Standard innhaldsskalering + Installer oppdatering + Installert versjon + Maks element på rader på heimskjermen + Tilpass element i navigasjonsskuffa + Klikk for å byte side + Byt side i navigasjonsskuffa ved fokus + Svevn-beskyttelse + Spel temamusikk + Overstyring av avspeling + Hugs valde faner + Tillat gjensjåing i \'Neste\' + Steglengde for søkelinje + Nyttig for feilsøking + Send app-loggar til gjeldande servar + Vil prøve å sende til sist tilkopla servar + Send krasjrapportar + Innstillingar + Hopp tilbake ved framhald av avspeling + Hopp tilbake + Handling for overhopping av reklame + Hopp framover + Handling for overhopping av intro + Handling for overhopping av outro + Handling for overhopping av førehandsvising + Handling for overhopping av samandrag + Oppdatering tilgjengeleg + URL for sjekk av app-oppdateringar + URL for oppdatering + Skriftstorleik + Skriftfarge + Gjennomsiktigheit for skrift + Kantstil + Kantfarge + Gjennomsiktigheit for bakgrunn + Bakgrunnsstil + Stil for undertekst + Bakgrunnsfarge + Nullstill + Feit skrift + Avspelingsmotor + MPV: Bruk maskinvaredekoding + Deaktiver dersom appen krasjar + MPV-val + ExoPlayer-val + Hopp over segment + Hopp over reklame + Hopp over førehandsvising + Hopp over samandrag + Hopp over outro + Hopp over intro + Sett + Filter + År + Tiår + Fjern + Dolby Vision + Dolby Atmos + MPV: Bruk gpu-next + Rediger mpv.conf + Forkast endringar? + Marg + Detaljert logging + Tast inn PIN + Logg inn automatisk + Logg inn via servar + Trykk i midten for å bekrefte + Krev PIN for profil + Bekreft PIN + Feil + PIN må vere 4 tastar eller lenger + Vil fjerne PIN + Storleik på biletmellomlager (MB) + Visingsval + Kolonner + Mellomrom + Bileteforhold + Vis detaljar + Bilettype + + Gjesteroller + Kantstorleik + Byting av bildeoppdateringsfrekvens + Automatisk + Bruk brukarnamn/passord + Logg inn + Generelt + Containar + Tittel + Kodek + Profil + Nivå + Oppløysing + Anamorfisk + Linjefletta + Bildefrekvens + Bit-dybde + Videoområde + Type videoområde + Fargerom + Fargeoverføring + Fargeprimærar + Pikselformat + Referanserammer + NAL + Språk + Layout + Kanalar + Samplingsfrekvens + AVC + Ja + Nei + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titlar + Repeter + Vis favorittkanalar først + Sorter kanalar etter sist sett + Fargekod program + Undertekstforseinking + Bakgrunnsstil + Ingen + Nedlastinga tek lang tid, du må kanskje starte avspelinga på nytt + + %s nedlasting + %s nedlastingar + + + %d time + %d timar + + + %d element + %d elementer + + + %d sekund + %d sekund + + Vel standardelementa som skal visast, andre vert skjulde + Sluttar %1$s diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 89c4b105..346cd2c6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -371,4 +371,8 @@ Ordenar canais por visualizações recentes Codificação de cores para os programas Atraso da legenda + Insira o endereço do servidor + Nenhum servidor encontrado + Estilo de fundo + Termina às %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2d317635..e8223887 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -339,4 +339,8 @@ 按最近观看的频道排序 节目颜色标记 字幕延迟 + 输入服务器地址 + 未找到服务器 + 背景幕样式 + 结束于 %1$s diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ef0da683..efa7096c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -183,8 +183,8 @@ #ABCDEFGHIJKLMNOPQRSTUVWXYZ 授權資訊 電視直播 - 標記整部電視劇為已觀看? - 標記整部電視劇為未觀看? + 標記整部劇為已觀看? + 標記整部劇為未觀看? 標記為未觀看 標記為已觀看 最大位元率 @@ -299,4 +299,48 @@ 使用帳號/密碼 登入 字幕同步 + 顯示標題 + 輸入伺服器位址 + 未找到伺服器 + 背景圖樣式 + 節目顏色標記 + 優先顯示最愛頻道 + 按最近觀看的頻道排序 + 結束於 %1$s + 一般 + 封裝格式 + 標題 + 編碼 + 解析度 + 變形寬銀幕 + 等級 + 編碼設定檔 + 語言 + + + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + 重複 + 隔行掃描 + 參考影格數 + 影格率 + NAL + 位元深度 + AVC + 聲道配置 + 聲道數 + 動態範圍 + 動態範圍類型 + 色彩空間 + 色彩轉換 + 色彩原色 + 像素格式 + 取樣率 + 進度條跳轉值 + 播放覆蓋設定 diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 59814e43..ae2a4ae1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -45,4 +45,9 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47122cca..58fd02b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -75,6 +75,7 @@ No scheduled recordings No update available None + Only Forced Subtitles Outro Path People @@ -104,6 +105,24 @@ Search Searching… + Voice search + Starting + Speak to search + Processing + Press back to cancel + Audio recording error + Voice recognition error + Microphone permission required + Network error + Network timeout + No speech recognized + Voice recognition busy + Server error + No speech detected + Unknown error + Failed to start voice recognition + Voice recognition timed out + Retry Select Server Select User Show debug info @@ -252,6 +271,22 @@ %d seconds + + Movies + Movie + Movies + + + TV Shows + TV Show + TV Shows + + + People + Person + People + + preference.update.threshold preference.update.lastTimestamp @@ -398,6 +433,34 @@ Color-code programs Subtitle delay Backdrop style + Resolution switching + Local + Play trailer + No trailers + Clear track choices + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Direct play Dolby Vision Profile 7 + Ignores device compatibility checks + + Discover + Request + Pending + Seerr integration + Remove Seerr Server + Seerr server added + Password + Username + URL + Trending + Upcoming Movies + Upcoming TV Shows + Request in 4K + AV1 software decoding Disabled diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..115a8e89 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml new file mode 100644 index 00000000..e8c9c685 --- /dev/null +++ b/app/src/main/seerr/seerr-api.yml @@ -0,0 +1,7776 @@ +openapi: '3.0.2' +info: + title: 'Seerr API' + version: '1.0.0' + description: | + This is the documentation for the Seerr API backend. + + Two primary authentication methods are supported: + + - **Cookie Authentication**: A valid sign-in to the `/auth/plex` or `/auth/local` will generate a valid authentication cookie. + - **API Key Authentication**: Sign-in is also possible by passing an `X-Api-Key` header along with a valid API Key generated by Seerr. +tags: + - name: public + description: Public API endpoints requiring no authentication. + - name: settings + description: Endpoints related to Seerr's settings and configuration. + - name: auth + description: Endpoints related to logging in or out, and the currently authenticated user. + - name: users + description: Endpoints related to user management. + - name: search + description: Endpoints related to search and discovery. + - name: request + description: Endpoints related to request management. + - name: movies + description: Endpoints related to retrieving movies and their details. + - name: tv + description: Endpoints related to retrieving TV series and their details. + - name: other + description: Endpoints related to other TMDB data + - name: person + description: Endpoints related to retrieving person details. + - name: media + description: Endpoints related to media management. + - name: collection + description: Endpoints related to retrieving collection details. + - name: service + description: Endpoints related to getting service (Radarr/Sonarr) details. + - name: watchlist + description: Collection of media to watch later + - name: blacklist + description: Blacklisted media from discovery page. +servers: + - url: '{server}/api/v1' + variables: + server: + default: http://localhost:5055 + +components: + schemas: + Blacklist: + type: object + properties: + tmdbId: + type: integer + example: 1 + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + userId: + type: integer + example: 1 + Watchlist: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + tmdbId: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + User: + type: object + properties: + id: + type: integer + example: 1 + readOnly: true + email: + type: string + example: 'hey@itsme.com' + readOnly: true + username: + type: string + plexUsername: + type: string + readOnly: true + plexToken: + type: string + readOnly: true + jellyfinAuthToken: + type: string + readOnly: true + userType: + type: integer + example: 1 + readOnly: true + permissions: + type: integer + example: 0 + avatar: + type: string + readOnly: true + createdAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-02T05:02:23.000Z' + readOnly: true + requestCount: + type: integer + example: 5 + readOnly: true + required: + - id + UserSettings: + type: object + properties: + username: + type: string + nullable: true + example: 'Mr User' + email: + type: string + example: 'user@example.com' + discordId: + type: string + nullable: true + example: '123456789' + locale: + type: string + nullable: true + example: 'en' + discoverRegion: + type: string + nullable: true + example: 'US' + streamingRegion: + type: string + nullable: true + example: 'US' + originalLanguage: + type: string + nullable: true + example: 'en' + movieQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of movie requests allowed' + example: 10 + movieQuotaDays: + type: integer + nullable: true + description: 'Time period in days for movie quota' + example: 30 + tvQuotaLimit: + type: integer + nullable: true + description: 'Maximum number of TV requests allowed' + example: 5 + tvQuotaDays: + type: integer + nullable: true + description: 'Time period in days for TV quota' + example: 14 + globalMovieQuotaDays: + type: integer + nullable: true + description: 'Global movie quota days setting' + example: 30 + globalMovieQuotaLimit: + type: integer + nullable: true + description: 'Global movie quota limit setting' + example: 10 + globalTvQuotaLimit: + type: integer + nullable: true + description: 'Global TV quota limit setting' + example: 5 + globalTvQuotaDays: + type: integer + nullable: true + description: 'Global TV quota days setting' + example: 14 + watchlistSyncMovies: + type: boolean + nullable: true + description: 'Enable watchlist sync for movies' + example: true + watchlistSyncTv: + type: boolean + nullable: true + description: 'Enable watchlist sync for TV' + example: false + MainSettings: + type: object + properties: + apiKey: + type: string + readOnly: true + appLanguage: + type: string + example: en + applicationTitle: + type: string + example: Seerr + applicationUrl: + type: string + example: https://os.example.com + hideAvailable: + type: boolean + example: false + partialRequestsEnabled: + type: boolean + example: false + localLogin: + type: boolean + example: true + mediaServerType: + type: integer + example: 1 + newPlexLogin: + type: boolean + example: true + defaultPermissions: + type: integer + example: 32 + enableSpecialEpisodes: + type: boolean + example: false + NetworkSettings: + type: object + properties: + csrfProtection: + type: boolean + example: false + forceIpv4First: + type: boolean + example: false + trustProxy: + type: boolean + example: false + proxy: + type: object + properties: + enabled: + type: boolean + example: false + hostname: + type: string + example: '' + port: + type: integer + example: 8080 + useSsl: + type: boolean + example: false + user: + type: string + example: '' + password: + type: string + example: '' + bypassFilter: + type: string + example: '' + bypassLocalAddresses: + type: boolean + example: true + dnsCache: + type: object + properties: + enabled: + type: boolean + example: false + forceMinTtl: + type: integer + example: 0 + forceMaxTtl: + type: integer + example: -1 + PlexLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + PlexSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + machineId: + type: string + example: '1234123412341234' + readOnly: true + ip: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + useSsl: + type: boolean + nullable: true + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/PlexLibrary' + webAppUrl: + type: string + nullable: true + example: 'https://app.plex.tv/desktop' + required: + - name + - machineId + - ip + - port + PlexConnection: + type: object + properties: + protocol: + type: string + example: 'https' + address: + type: string + example: '127.0.0.1' + port: + type: integer + example: 32400 + uri: + type: string + example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' + local: + type: boolean + example: true + status: + type: integer + example: 200 + message: + type: string + example: 'OK' + required: + - protocol + - address + - port + - uri + - local + PlexDevice: + type: object + properties: + name: + type: string + example: 'My Plex Server' + product: + type: string + example: 'Plex Media Server' + productVersion: + type: string + example: '1.21' + platform: + type: string + example: 'Linux' + platformVersion: + type: string + example: 'default/linux/amd64/17.1/systemd' + device: + type: string + example: 'PC' + clientIdentifier: + type: string + example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' + createdAt: + type: string + example: '2021-01-01T00:00:00.000Z' + lastSeenAt: + type: string + example: '2021-01-01T00:00:00.000Z' + provides: + type: array + items: + type: string + example: 'server' + owned: + type: boolean + example: true + ownerID: + type: string + example: '12345' + home: + type: boolean + example: true + sourceTitle: + type: string + example: 'xyzabc' + accessToken: + type: string + example: 'supersecretaccesstoken' + publicAddress: + type: string + example: '127.0.0.1' + httpsRequired: + type: boolean + example: true + synced: + type: boolean + example: true + relay: + type: boolean + example: true + dnsRebindingProtection: + type: boolean + example: false + natLoopbackSupported: + type: boolean + example: false + publicAddressMatches: + type: boolean + example: false + presence: + type: boolean + example: true + connection: + type: array + items: + $ref: '#/components/schemas/PlexConnection' + required: + - name + - product + - productVersion + - platform + - device + - clientIdentifier + - createdAt + - lastSeenAt + - provides + - owned + - connection + JellyfinLibrary: + type: object + properties: + id: + type: string + name: + type: string + example: Movies + enabled: + type: boolean + example: false + required: + - id + - name + - enabled + JellyfinSettings: + type: object + properties: + name: + type: string + example: 'Main Server' + readOnly: true + hostname: + type: string + example: 'http://my.jellyfin.host' + externalHostname: + type: string + example: 'http://my.jellyfin.host' + jellyfinForgotPasswordUrl: + type: string + example: 'http://my.jellyfin.host/web/index.html#!/forgotpassword.html' + adminUser: + type: string + example: 'admin' + adminPass: + type: string + example: 'mypassword' + libraries: + type: array + readOnly: true + items: + $ref: '#/components/schemas/JellyfinLibrary' + serverID: + type: string + readOnly: true + MetadataSettings: + type: object + properties: + settings: + type: object + properties: + tv: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + anime: + type: string + enum: [tvdb, tmdb] + example: 'tvdb' + TautulliSettings: + type: object + properties: + hostname: + type: string + nullable: true + example: 'tautulli.example.com' + port: + type: integer + nullable: true + example: 8181 + useSsl: + type: boolean + nullable: true + apiKey: + type: string + nullable: true + externalUrl: + type: string + nullable: true + RadarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Radarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/movies' + is4k: + type: boolean + example: false + minimumAvailability: + type: string + example: 'In Cinema' + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - minimumAvailability + - isDefault + SonarrSettings: + type: object + properties: + id: + type: integer + example: 0 + readOnly: true + name: + type: string + example: 'Sonarr Main' + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: 'exampleapikey' + useSsl: + type: boolean + example: false + baseUrl: + type: string + activeProfileId: + type: integer + example: 1 + activeProfileName: + type: string + example: 720p/1080p + activeDirectory: + type: string + example: '/tv/' + activeLanguageProfileId: + type: integer + example: 1 + activeAnimeProfileId: + type: integer + nullable: true + activeAnimeLanguageProfileId: + type: integer + nullable: true + activeAnimeProfileName: + type: string + example: 720p/1080p + nullable: true + activeAnimeDirectory: + type: string + nullable: true + is4k: + type: boolean + example: false + enableSeasonFolders: + type: boolean + example: false + isDefault: + type: boolean + example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false + required: + - name + - hostname + - port + - apiKey + - useSsl + - activeProfileId + - activeProfileName + - activeDirectory + - is4k + - enableSeasonFolders + - isDefault + ServarrTag: + type: object + properties: + id: + type: integer + example: 1 + label: + type: string + example: A Label + PublicSettings: + type: object + properties: + initialized: + type: boolean + example: false + movie4kEnabled: + type: boolean + series4kEnabled: + type: boolean + MovieResult: + type: object + required: + - id + - mediaType + # - title + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + title: + type: string + example: Movie Title + originalTitle: + type: string + example: Original Movie Title + releaseDate: + type: string + adult: + type: boolean + example: false + video: + type: boolean + example: false + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + TvResult: + type: object + properties: + id: + type: integer + example: 1234 + mediaType: + type: string + popularity: + type: number + example: 10 + posterPath: + type: string + backdropPath: + type: string + voteCount: + type: integer + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + overview: + type: string + example: 'Overview of the movie' + originalLanguage: + type: string + example: 'en' + name: + type: string + example: TV Show Name + originalName: + type: string + example: Original TV Show Name + originCountry: + type: array + items: + type: string + firstAirDate: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + PersonResult: + type: object + properties: + id: + type: integer + example: 12345 + profilePath: + type: string + adult: + type: boolean + example: false + mediaType: + type: string + default: 'person' + knownFor: + type: array + items: + oneOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + Genre: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: Adventure + Company: + type: object + properties: + id: + type: integer + example: 1 + logo_path: + type: string + nullable: true + name: + type: string + ProductionCompany: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + Network: + type: object + properties: + id: + type: integer + example: 1 + logoPath: + type: string + nullable: true + originCountry: + type: string + name: + type: string + RelatedVideo: + type: object + properties: + url: + type: string + example: https://www.youtube.com/watch?v=9qhL2_UxXM0/ + key: + type: string + example: 9qhL2_UxXM0 + name: + type: string + example: Trailer for some movie (1978) + size: + type: integer + example: 1080 + type: + type: string + example: Trailer + enum: + - Clip + - Teaser + - Trailer + - Featurette + - Opening Credits + - Behind the Scenes + - Bloopers + site: + type: string + enum: + - 'YouTube' + MovieDetails: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + imdbId: + type: string + example: 'tt123' + adult: + type: boolean + backdropPath: + type: string + posterPath: + type: string + budget: + type: integer + example: 1000000 + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + relatedVideos: + type: array + items: + $ref: '#/components/schemas/RelatedVideo' + originalLanguage: + type: string + originalTitle: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + releaseDate: + type: string + releases: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + nullable: true + release_dates: + type: array + items: + type: object + properties: + certification: + type: string + example: 'PG-13' + iso_639_1: + type: string + nullable: true + note: + type: string + nullable: true + example: 'Blu ray' + release_date: + type: string + example: '2017-07-12T00:00:00.000Z' + type: + type: integer + example: 1 + revenue: + type: number + nullable: true + runtime: + type: number + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + status: + type: string + tagline: + type: string + title: + type: string + video: + type: boolean + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + collection: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: A collection + posterPath: + type: string + backdropPath: + type: string + externalIds: + $ref: '#/components/schemas/ExternalIds' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + Episode: + type: object + properties: + id: + type: integer + name: + type: string + airDate: + type: string + nullable: true + episodeNumber: + type: integer + overview: + type: string + productionCode: + type: string + seasonNumber: + type: integer + showId: + type: integer + stillPath: + type: string + nullable: true + voteAverage: + type: number + voteCount: + type: integer + Season: + type: object + properties: + id: + type: integer + airDate: + type: string + nullable: true + episodeCount: + type: integer + name: + type: string + overview: + type: string + posterPath: + type: string + seasonNumber: + type: integer + episodes: + type: array + items: + $ref: '#/components/schemas/Episode' + TvDetails: + type: object + properties: + id: + type: integer + example: 123 + backdropPath: + type: string + posterPath: + type: string + contentRatings: + type: object + properties: + results: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: 'US' + rating: + type: string + example: 'TV-14' + createdBy: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + gender: + type: integer + profilePath: + type: string + nullable: true + episodeRunTime: + type: array + items: + type: integer + firstAirDate: + type: string + genres: + type: array + items: + $ref: '#/components/schemas/Genre' + homepage: + type: string + inProduction: + type: boolean + languages: + type: array + items: + type: string + lastAirDate: + type: string + lastEpisodeToAir: + $ref: '#/components/schemas/Episode' + name: + type: string + nextEpisodeToAir: + $ref: '#/components/schemas/Episode' + networks: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + numberOfEpisodes: + type: integer + numberOfSeason: + type: integer + originCountry: + type: array + items: + type: string + originalLanguage: + type: string + originalName: + type: string + overview: + type: string + popularity: + type: number + productionCompanies: + type: array + items: + $ref: '#/components/schemas/ProductionCompany' + productionCountries: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + name: + type: string + spokenLanguages: + type: array + items: + $ref: '#/components/schemas/SpokenLanguage' + seasons: + type: array + items: + $ref: '#/components/schemas/Season' + status: + type: string + tagline: + type: string + type: + type: string + voteAverage: + type: number + voteCount: + type: integer + credits: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + externalIds: + $ref: '#/components/schemas/ExternalIds' + keywords: + type: array + items: + $ref: '#/components/schemas/Keyword' + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + watchProviders: + type: array + items: + $ref: '#/components/schemas/WatchProviders' + MediaRequest: + type: object + properties: + id: + type: integer + example: 123 + readOnly: true + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true + media: + $ref: '#/components/schemas/MediaInfo' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + requestedBy: + $ref: '#/components/schemas/User' + modifiedBy: + anyOf: + - $ref: '#/components/schemas/User' + - type: string + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + type: + type: string + required: + - id + - status + MediaInfo: + type: object + properties: + id: + type: integer + readOnly: true + tmdbId: + type: integer + readOnly: true + tvdbId: + type: integer + readOnly: true + nullable: true + status: + type: integer + example: 0 + description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + requests: + type: array + readOnly: true + items: + $ref: '#/components/schemas/MediaRequest' + createdAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + updatedAt: + type: string + example: '2020-09-12T10:00:27.000Z' + readOnly: true + jellyfinMediaId: + type: string + jellyfinMediaId4k: + type: string + Cast: + type: object + properties: + id: + type: integer + example: 123 + castid: + type: integer + example: 1 + character: + type: string + example: Some Character Name + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + order: + type: integer + profilePath: + type: string + nullable: true + Crew: + type: object + properties: + id: + type: integer + example: 123 + creditId: + type: string + gender: + type: integer + name: + type: string + example: Some Persons Name + job: + type: string + department: + type: string + profilePath: + type: string + nullable: true + ExternalIds: + type: object + properties: + facebookId: + type: string + nullable: true + freebaseId: + type: string + nullable: true + freebaseMid: + type: string + nullable: true + imdbId: + type: string + nullable: true + instagramId: + type: string + nullable: true + tvdbid: + type: integer + nullable: true + tvrageid: + type: integer + nullable: true + twitterId: + type: string + nullable: true + ServiceProfile: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 720p/1080p + PageInfo: + type: object + properties: + page: + type: integer + example: 1 + pages: + type: integer + example: 10 + results: + type: integer + example: 100 + DiscordSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAvatarUrl: + type: string + webhookUrl: + type: string + webhookRoleId: + type: string + enableMentions: + type: boolean + SlackSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + WebPushSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + WebhookSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + webhookUrl: + type: string + authHeader: + type: string + jsonPayload: + type: string + supportVariables: + type: boolean + example: false + TelegramSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + botUsername: + type: string + botAPI: + type: string + chatId: + type: string + messageThreadId: + type: string + sendSilently: + type: boolean + PushbulletSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + channelTag: + type: string + nullable: true + PushoverSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + accessToken: + type: string + userToken: + type: string + sound: + type: string + GotifySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + token: + type: string + NtfySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + url: + type: string + topic: + type: string + authMethodUsernamePassword: + type: boolean + username: + type: string + password: + type: string + authMethodToken: + type: boolean + token: + type: string + NotificationEmailSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: integer + example: 2 + options: + type: object + properties: + emailFrom: + type: string + example: no-reply@example.com + senderName: + type: string + example: Seerr + smtpHost: + type: string + example: 127.0.0.1 + smtpPort: + type: integer + example: 465 + secure: + type: boolean + example: false + ignoreTls: + type: boolean + example: false + requireTls: + type: boolean + example: false + authUser: + type: string + nullable: true + authPass: + type: string + nullable: true + allowSelfSigned: + type: boolean + example: false + Job: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + interval: + type: string + enum: [short, long, fixed] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + PersonDetails: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + deathday: + type: string + knownForDepartment: + type: string + alsoKnownAs: + type: array + items: + type: string + gender: + type: string + biography: + type: string + popularity: + type: string + placeOfBirth: + type: string + profilePath: + type: string + adult: + type: boolean + imdbId: + type: string + homepage: + type: string + CreditCast: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + character: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + CreditCrew: + type: object + properties: + id: + type: integer + example: 1 + originalLanguage: + type: string + episodeCount: + type: integer + overview: + type: string + originCountry: + type: array + items: + type: string + originalName: + type: string + voteCount: + type: integer + name: + type: string + mediaType: + type: string + popularity: + type: number + creditId: + type: string + backdropPath: + type: string + firstAirDate: + type: string + voteAverage: + type: number + genreIds: + type: array + items: + type: integer + posterPath: + type: string + profilePath: + type: string + nullable: true + originalTitle: + type: string + video: + type: boolean + title: + type: string + adult: + type: boolean + releaseDate: + type: string + department: + type: string + job: + type: string + mediaInfo: + $ref: '#/components/schemas/MediaInfo' + Keyword: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: 'anime' + SpokenLanguage: + type: object + properties: + englishName: + type: string + example: 'English' + nullable: true + iso_639_1: + type: string + example: 'en' + name: + type: string + example: 'English' + Collection: + type: object + properties: + id: + type: integer + example: 123 + name: + type: string + example: A Movie Collection + overview: + type: string + example: Overview of collection + posterPath: + type: string + backdropPath: + type: string + parts: + type: array + items: + $ref: '#/components/schemas/MovieResult' + SonarrSeries: + type: object + properties: + title: + type: string + example: COVID-25 + sortTitle: + type: string + example: covid 25 + seasonCount: + type: integer + example: 1 + status: + type: string + example: upcoming + overview: + type: string + example: The thread is picked up again by Marianne Schmidt which ... + network: + type: string + example: CBS + airTime: + type: string + example: 02:15 + images: + type: array + items: + type: object + properties: + coverType: + type: string + example: banner + url: + type: string + example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg + remotePoster: + type: string + example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg + seasons: + type: array + items: + type: object + properties: + seasonNumber: + type: integer + example: 1 + monitored: + type: boolean + example: true + year: + type: integer + example: 2015 + path: + type: string + profileid: + type: integer + languageProfileid: + type: integer + seasonFolder: + type: boolean + monitored: + type: boolean + useSceneNumbering: + type: boolean + runtime: + type: number + tvdbid: + type: integer + example: 12345 + tvRageid: + type: integer + tvMazeid: + type: integer + firstAired: + type: string + lastInfoSync: + type: string + nullable: true + seriesType: + type: string + cleanTitle: + type: string + imdbId: + type: string + titleSlug: + type: string + certification: + type: string + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + added: + type: string + ratings: + type: array + items: + type: object + properties: + votes: + type: integer + value: + type: integer + qualityProfileid: + type: integer + id: + type: integer + nullable: true + rootFolderPath: + type: string + nullable: true + addOptions: + type: array + items: + type: object + properties: + ignoreEpisodesWithFiles: + type: boolean + nullable: true + ignoreEpisodesWithoutFiles: + type: boolean + nullable: true + searchForMissingEpisodes: + type: boolean + nullable: true + UserSettingsNotifications: + type: object + properties: + notificationTypes: + $ref: '#/components/schemas/NotificationAgentTypes' + emailEnabled: + type: boolean + pgpKey: + type: string + nullable: true + discordEnabled: + type: boolean + discordEnabledTypes: + type: integer + nullable: true + discordId: + type: string + nullable: true + pushbulletAccessToken: + type: string + nullable: true + pushoverApplicationToken: + type: string + nullable: true + pushoverUserKey: + type: string + nullable: true + pushoverSound: + type: string + nullable: true + telegramEnabled: + type: boolean + telegramBotUsername: + type: string + nullable: true + telegramChatId: + type: string + nullable: true + telegramMessageThreadId: + type: string + nullable: true + telegramSendSilently: + type: boolean + nullable: true + NotificationAgentTypes: + type: object + properties: + discord: + type: integer + email: + type: integer + pushbullet: + type: integer + pushover: + type: integer + slack: + type: integer + telegram: + type: integer + webhook: + type: integer + webpush: + type: integer + WatchProviders: + type: object + properties: + iso_3166_1: + type: string + link: + type: string + buy: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + flatrate: + items: + $ref: '#/components/schemas/WatchProviderDetails' + WatchProviderDetails: + type: object + properties: + displayPriority: + type: integer + logoPath: + type: string + id: + type: integer + name: + type: string + Issue: + type: object + properties: + id: + type: integer + example: 1 + issueType: + type: integer + example: 1 + media: + $ref: '#/components/schemas/MediaInfo' + createdBy: + $ref: '#/components/schemas/User' + modifiedBy: + $ref: '#/components/schemas/User' + comments: + type: array + items: + $ref: '#/components/schemas/IssueComment' + IssueComment: + type: object + properties: + id: + type: integer + example: 1 + user: + $ref: '#/components/schemas/User' + message: + type: string + example: A comment + DiscoverSlider: + type: object + properties: + id: + type: integer + example: 1 + type: + type: integer + example: 1 + title: + type: string + nullable: true + isBuiltIn: + type: boolean + enabled: + type: boolean + data: + type: string + example: '1234' + nullable: true + required: + - type + - enabled + - title + - data + WatchProviderRegion: + type: object + properties: + iso_3166_1: + type: string + english_name: + type: string + native_name: + type: string + OverrideRule: + type: object + properties: + id: + type: string + Certification: + type: object + properties: + certification: + type: string + example: 'PG-13' + meaning: + type: string + example: 'Some material may be inappropriate for children under 13.' + nullable: true + order: + type: integer + example: 3 + nullable: true + required: + - certification + + CertificationResponse: + type: object + properties: + certifications: + type: object + additionalProperties: + type: array + items: + $ref: '#/components/schemas/Certification' + example: + certifications: + US: + - certification: 'G' + meaning: 'All ages admitted' + order: 1 + - certification: 'PG' + meaning: 'Some material may not be suitable for children under 10.' + order: 2 + securitySchemes: + cookieAuth: + type: apiKey + name: connect.sid + in: cookie + apiKey: + type: apiKey + in: header + name: X-Api-Key + +paths: + /status: + get: + summary: Get Seerr status + description: Returns the current Seerr status in a JSON object. + security: [] + tags: + - public + responses: + '200': + description: Returned status + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: 1.0.0 + commitTag: + type: string + updateAvailable: + type: boolean + commitsBehind: + type: integer + restartRequired: + type: boolean + /status/appdata: + get: + summary: Get application data volume status + description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. + security: [] + tags: + - public + responses: + '200': + description: Application data volume status and path + content: + application/json: + schema: + type: object + properties: + appData: + type: boolean + example: true + appDataPath: + type: string + example: /app/config + appDataPermissions: + type: boolean + example: true + /settings/main: + get: + summary: Get main settings + description: Retrieves all main settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update main settings + description: Updates main settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/network: + get: + summary: Get network settings + description: Retrieves all network settings in a JSON object. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + post: + summary: Update network settings + description: Updates network settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NetworkSettings' + /settings/main/regenerate: + post: + summary: Get main settings with newly-generated API key + description: Returns main settings in a JSON object, using the new API key. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MainSettings' + /settings/jellyfin: + get: + summary: Get Jellyfin settings + description: Retrieves current Jellyfin settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + post: + summary: Update Jellyfin settings + description: Updates Jellyfin settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/JellyfinSettings' + /settings/jellyfin/library: + get: + summary: Get Jellyfin libraries + description: Returns a list of Jellyfin libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Jellyfin server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Jellyfin libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/jellyfin/users: + get: + summary: Get Jellyfin Users + description: Returns a list of Jellyfin Users in a JSON array. + tags: + - settings + - users + responses: + '200': + description: Jellyfin users returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + username: + type: string + id: + type: string + thumb: + type: string + email: + type: string + /settings/jellyfin/sync: + get: + summary: Get status of full Jellyfin library sync + description: Returns sync progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + post: + summary: Start full Jellyfin library sync + description: Runs a full Jellyfin library sync and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Jellyfin sync + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/JellyfinLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/JellyfinLibrary' + /settings/plex: + get: + summary: Get Plex settings + description: Retrieves current Plex settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + post: + summary: Update Plex settings + description: Updates Plex settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PlexSettings' + /settings/plex/library: + get: + summary: Get Plex libraries + description: Returns a list of Plex libraries in a JSON array. + tags: + - settings + parameters: + - in: query + name: sync + description: Syncs the current libraries with the current Plex server + schema: + type: string + nullable: true + - in: query + name: enable + explode: false + allowReserved: true + description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + schema: + type: string + nullable: true + responses: + '200': + description: 'Plex libraries returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/sync: + get: + summary: Get status of full Plex library scan + description: Returns scan progress in a JSON array. + tags: + - settings + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + post: + summary: Start full Plex library scan + description: Runs a full Plex library scan and returns the progress in a JSON array. + tags: + - settings + requestBody: + content: + application/json: + schema: + type: object + properties: + cancel: + type: boolean + example: false + start: + type: boolean + example: false + responses: + '200': + description: Status of Plex scan + content: + application/json: + schema: + type: object + properties: + running: + type: boolean + example: false + progress: + type: integer + example: 0 + total: + type: integer + example: 100 + currentLibrary: + $ref: '#/components/schemas/PlexLibrary' + libraries: + type: array + items: + $ref: '#/components/schemas/PlexLibrary' + /settings/plex/devices/servers: + get: + summary: Gets the user's available Plex servers + description: Returns a list of available Plex servers and their connectivity state + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexDevice' + /settings/plex/users: + get: + summary: Get Plex users + description: | + Returns a list of Plex users in a JSON array. + + Requires the `MANAGE_USERS` permission. + tags: + - settings + - users + responses: + '200': + description: Plex users + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: string + title: + type: string + username: + type: string + email: + type: string + thumb: + type: string + /settings/metadatas: + get: + summary: Get Metadata settings + description: Retrieves current Metadata settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + put: + summary: Update Metadata settings + description: Updates Metadata settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/MetadataSettings' + /settings/metadatas/test: + post: + summary: Test Provider configuration + description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tmdb: + type: boolean + example: true + tvdb: + type: boolean + example: true + responses: + '200': + description: Succesfully connected to TVDB + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Successfully connected to TVDB' + /settings/tautulli: + get: + summary: Get Tautulli settings + description: Retrieves current Tautulli settings. + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + post: + summary: Update Tautulli settings + description: Updates Tautulli settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TautulliSettings' + /settings/radarr: + get: + summary: Get Radarr settings + description: Returns all Radarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + post: + summary: Create Radarr instance + description: Creates a new Radarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '201': + description: 'New Radarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/test: + post: + summary: Test Radarr configuration + description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 7878 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Radarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/radarr/{radarrId}: + put: + summary: Update Radarr instance + description: Updates an existing Radarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + delete: + summary: Delete Radarr instance + description: Deletes an existing Radarr instance based on the radarrId parameter. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: 'Radarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/RadarrSettings' + /settings/radarr/{radarrId}/profiles: + get: + summary: Get available Radarr profiles + description: Returns a list of profiles available on the Radarr server instance in a JSON array. + tags: + - settings + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + description: Radarr instance ID + responses: + '200': + description: Returned list of profiles + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr: + get: + summary: Get Sonarr settings + description: Returns all Sonarr settings in a JSON array. + tags: + - settings + responses: + '200': + description: 'Values were returned' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + post: + summary: Create Sonarr instance + description: Creates a new Sonarr instance from the request body. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '201': + description: 'New Sonarr instance created' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/sonarr/test: + post: + summary: Test Sonarr configuration + description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + hostname: + type: string + example: '127.0.0.1' + port: + type: integer + example: 8989 + apiKey: + type: string + example: yourapikey + useSsl: + type: boolean + example: false + baseUrl: + type: string + required: + - hostname + - port + - apiKey + - useSsl + responses: + '200': + description: Succesfully connected to Sonarr instance + content: + application/json: + schema: + type: object + properties: + profiles: + type: array + items: + $ref: '#/components/schemas/ServiceProfile' + /settings/sonarr/{sonarrId}: + put: + summary: Update Sonarr instance + description: Updates an existing Sonarr instance with the provided values. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + delete: + summary: Delete Sonarr instance + description: Deletes an existing Sonarr instance based on the sonarrId parameter. + tags: + - settings + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + description: Sonarr instance ID + responses: + '200': + description: 'Sonarr instance updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SonarrSettings' + /settings/public: + get: + summary: Get public settings + security: [] + description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/initialize: + post: + summary: Initialize application + description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. + tags: + - settings + responses: + '200': + description: Public settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/PublicSettings' + /settings/jobs: + get: + summary: Get scheduled jobs + description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. + tags: + - settings + responses: + '200': + description: Scheduled jobs returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/run: + post: + summary: Invoke a specific job + description: Invokes a specific job to run. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Invoked job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/cancel: + post: + summary: Cancel a specific job + description: Cancels a specific job. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Canceled job returned + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/jobs/{jobId}/schedule: + post: + summary: Modify job schedule + description: Re-registers the job with the schedule specified. Will return the job in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + schedule: + type: string + example: '0 */5 * * * *' + responses: + '200': + description: Rescheduled job + content: + application/json: + schema: + $ref: '#/components/schemas/Job' + /settings/cache: + get: + summary: Get a list of active caches + description: Retrieves a list of all active caches and their current stats. + tags: + - settings + responses: + '200': + description: Caches returned + content: + application/json: + schema: + type: object + properties: + imageCache: + type: object + properties: + tmdb: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + avatar: + type: object + properties: + size: + type: integer + example: 123456 + imageCount: + type: integer + example: 123 + dnsCache: + type: object + properties: + stats: + type: object + properties: + size: + type: integer + example: 1 + maxSize: + type: integer + example: 500 + hits: + type: integer + example: 19 + misses: + type: integer + example: 1 + failures: + type: integer + example: 0 + ipv4Fallbacks: + type: integer + example: 0 + hitRate: + type: integer + example: 0.95 + entries: + type: array + additionalProperties: + type: object + properties: + addresses: + type: object + properties: + ipv4: + type: integer + example: 1 + ipv6: + type: integer + example: 1 + activeAddress: + type: string + example: 127.0.0.1 + family: + type: integer + example: 4 + age: + type: integer + example: 10 + ttl: + type: integer + example: 10 + networkErrors: + type: integer + example: 0 + hits: + type: integer + example: 1 + misses: + type: integer + example: 1 + apiCaches: + type: array + items: + type: object + properties: + id: + type: string + example: cache-id + name: + type: string + example: cache name + stats: + type: object + properties: + hits: + type: integer + misses: + type: integer + keys: + type: integer + ksize: + type: integer + vsize: + type: integer + /settings/cache/{cacheId}/flush: + post: + summary: Flush a specific cache + description: Flushes all data from the cache ID provided + tags: + - settings + parameters: + - in: path + name: cacheId + required: true + schema: + type: string + responses: + '204': + description: 'Flushed cache' + /settings/cache/dns/{dnsEntry}/flush: + post: + summary: Flush a specific DNS cache entry + description: Flushes a specific DNS cache entry + tags: + - settings + parameters: + - in: path + name: dnsEntry + required: true + schema: + type: string + responses: + '204': + description: 'Flushed dns cache' + /settings/logs: + get: + summary: Returns logs + description: Returns list of all log items and details + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [debug, info, warn, error] + default: debug + - in: query + name: search + schema: + type: string + nullable: true + example: plex + responses: + '200': + description: Server log returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + label: + type: string + example: server + level: + type: string + example: info + message: + type: string + example: Server ready on port 5055 + timestamp: + type: string + example: '2020-12-15T16:20:00.069Z' + /settings/notifications/email: + get: + summary: Get email notification settings + description: Returns current email notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned email settings + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + post: + summary: Update email notification settings + description: Updates email notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + /settings/notifications/email/test: + post: + summary: Test email settings + description: Sends a test notification to the email agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationEmailSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/discord: + get: + summary: Get Discord notification settings + description: Returns current Discord notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Discord settings + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + post: + summary: Update Discord notification settings + description: Updates Discord notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + /settings/notifications/discord/test: + post: + summary: Test Discord settings + description: Sends a test notification to the Discord agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DiscordSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushbullet: + get: + summary: Get Pushbullet notification settings + description: Returns current Pushbullet notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushbullet settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + post: + summary: Update Pushbullet notification settings + description: Update Pushbullet notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + /settings/notifications/pushbullet/test: + post: + summary: Test Pushbullet settings + description: Sends a test notification to the Pushbullet agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushbulletSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover: + get: + summary: Get Pushover notification settings + description: Returns current Pushover notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + post: + summary: Update Pushover notification settings + description: Update Pushover notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + /settings/notifications/pushover/test: + post: + summary: Test Pushover settings + description: Sends a test notification to the Pushover agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/pushover/sounds: + get: + summary: Get Pushover sounds + description: Returns valid Pushover sound options in a JSON array. + tags: + - settings + parameters: + - in: query + name: token + required: true + schema: + type: string + nullable: false + responses: + '200': + description: Returned Pushover settings + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + description: + type: string + /settings/notifications/gotify: + get: + summary: Get Gotify notification settings + description: Returns current Gotify notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Gotify settings + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + post: + summary: Update Gotify notification settings + description: Update Gotify notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + /settings/notifications/gotify/test: + post: + summary: Test Gotify settings + description: Sends a test notification to the Gotify agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/ntfy: + get: + summary: Get ntfy.sh notification settings + description: Returns current ntfy.sh notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned ntfy.sh settings + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + post: + summary: Update ntfy.sh notification settings + description: Update ntfy.sh notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + /settings/notifications/ntfy/test: + post: + summary: Test ntfy.sh settings + description: Sends a test notification to the ntfy.sh agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NtfySettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/slack: + get: + summary: Get Slack notification settings + description: Returns current Slack notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned slack settings + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + post: + summary: Update Slack notification settings + description: Updates Slack notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + /settings/notifications/slack/test: + post: + summary: Test Slack settings + description: Sends a test notification to the Slack agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/telegram: + get: + summary: Get Telegram notification settings + description: Returns current Telegram notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Telegram settings + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + post: + summary: Update Telegram notification settings + description: Update Telegram notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + /settings/notifications/telegram/test: + post: + summary: Test Telegram settings + description: Sends a test notification to the Telegram agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TelegramSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webpush: + get: + summary: Get Web Push notification settings + description: Returns current Web Push notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned web push settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + post: + summary: Update Web Push notification settings + description: Updates Web Push notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + /settings/notifications/webpush/test: + post: + summary: Test Web Push settings + description: Sends a test notification to the Web Push agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebPushSettings' + responses: + '204': + description: Test notification attempted + /settings/notifications/webhook: + get: + summary: Get webhook notification settings + description: Returns current webhook notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned webhook settings + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + post: + summary: Update webhook notification settings + description: Updates webhook notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + /settings/notifications/webhook/test: + post: + summary: Test webhook settings + description: Sends a test notification to the webhook agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookSettings' + responses: + '204': + description: Test notification attempted + /settings/discover: + get: + summary: Get all discover sliders + description: Returns all discovery sliders. Built-in and custom made. + tags: + - settings + responses: + '200': + description: Returned all discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + post: + summary: Batch update all sliders. + description: | + Batch update all sliders at once. Should also be used for creation. Will only update sliders provided + and will not delete any sliders not present in the request. If a slider is missing a required field, + it will be ignored. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + responses: + '200': + description: Returned all newly updated discovery sliders + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/{sliderId}: + put: + summary: Update a single slider + description: | + Updates a single slider and return the newly updated slider. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'Slider Title' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + delete: + summary: Delete slider by ID + description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. + tags: + - settings + parameters: + - in: path + name: sliderId + required: true + schema: + type: integer + responses: + '200': + description: Slider successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/add: + post: + summary: Add a new slider + description: | + Add a single slider and return the newly created slider. Requires the `ADMIN` permission. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: 'New Slider' + type: + type: integer + example: 1 + data: + type: string + example: '1' + responses: + '200': + description: Returns newly added discovery slider + content: + application/json: + schema: + $ref: '#/components/schemas/DiscoverSlider' + /settings/discover/reset: + get: + summary: Reset all discover sliders + description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. + tags: + - settings + responses: + '204': + description: All sliders reset to defaults + /settings/about: + get: + summary: Get server stats + description: Returns current server stats in a JSON object. + tags: + - settings + responses: + '200': + description: Returned about settings + content: + application/json: + schema: + type: object + properties: + version: + type: string + example: '1.0.0' + totalRequests: + type: integer + example: 100 + totalMediaItems: + type: integer + example: 100 + tz: + type: string + nullable: true + example: Asia/Tokyo + appDataPath: + type: string + example: /app/config + /auth/me: + get: + summary: Get logged-in user + description: Returns the currently logged-in user. + tags: + - auth + - users + responses: + '200': + description: Object containing the logged-in user in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /auth/plex: + post: + summary: Sign in using a Plex token + description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + /auth/jellyfin: + post: + summary: Sign in using a Jellyfin username and password + description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + password: + type: string + hostname: + type: string + email: + type: string + serverType: + type: integer + required: + - username + - password + /auth/local: + post: + summary: Sign in using a local account + description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + security: [] + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + required: + - email + - password + /auth/logout: + post: + summary: Sign out and clear session cookie + description: Completely clear the session cookie and associated values, effectively signing the user out. + tags: + - auth + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + /auth/reset-password: + post: + summary: Send a reset password email + description: Sends a reset password email to the email if the user exists + security: [] + tags: + - users + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + required: + - email + /auth/reset-password/{guid}: + post: + summary: Reset the password for a user + description: Resets the password for a user if the given guid is connected to a user + security: [] + tags: + - users + parameters: + - in: path + name: guid + required: true + schema: + type: string + example: '9afef5a7-ec89-4d5f-9397-261e96970b50' + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: 'ok' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + password: + type: string + required: + - password + /user: + get: + summary: Get all users + description: Returns all users in a JSON object. + tags: + - users + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [created, updated, requests, displayname] + default: created + - in: query + name: q + required: false + schema: + type: string + - in: query + name: includeIds + required: false + schema: + type: string + responses: + '200': + description: A JSON array of all users + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/User' + post: + summary: Create new user + description: | + Creates a new user. Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + example: 'hey@itsme.com' + username: + type: string + permissions: + type: integer + responses: + '201': + description: The created user + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update batch of users + description: | + Update users with given IDs with provided values in request `body.settings`. You cannot update users' Plex tokens through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + permissions: + type: integer + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-plex: + post: + summary: Import all users from Plex + description: | + Fetches and imports users from the Plex server. If a list of Plex IDs is provided in the request body, only the specified users will be imported. Otherwise, all users will be imported. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + plexIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/import-from-jellyfin: + post: + summary: Import all users from Jellyfin + description: | + Fetches and imports users from the Jellyfin server. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + jellyfinUserIds: + type: array + items: + type: string + responses: + '201': + description: A list of the newly created users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/registerPushSubscription: + post: + summary: Register a web push /user/registerPushSubscription + description: Registers a web push subscription for the logged-in user + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + auth: + type: string + p256dh: + type: string + userAgent: + type: string + required: + - endpoint + - auth + - p256dh + responses: + '204': + description: Successfully registered push subscription + /user/{userId}/pushSubscriptions: + get: + summary: Get all web push notification settings for a user + description: | + Returns all web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + /user/{userId}/pushSubscription/{endpoint}: + get: + summary: Get web push notification settings for a user + description: | + Returns web push notification settings for a user in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '200': + description: User web push notification settings in JSON + content: + application/json: + schema: + type: object + properties: + endpoint: + type: string + p256dh: + type: string + auth: + type: string + userAgent: + type: string + delete: + summary: Delete user push subscription by key + description: Deletes the user push subscription with the provided key. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: path + name: endpoint + required: true + schema: + type: string + responses: + '204': + description: Successfully removed user push subscription + /user/{userId}: + get: + summary: Get user by ID + description: | + Retrieves user details in a JSON object. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users details in JSON + content: + application/json: + schema: + $ref: '#/components/schemas/User' + put: + summary: Update a user by user ID + description: | + Update a user with the provided values. You cannot update a user's Plex token through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + summary: Delete user by ID + description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /user/{userId}/requests: + get: + summary: Get requests for a specific user + description: | + Retrieves a user's requests in a JSON object. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + responses: + '200': + description: User's requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + /user/{userId}/quota: + get: + summary: Get quotas for a specific user + description: | + Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User quota details in JSON + content: + application/json: + schema: + type: object + properties: + movie: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + tv: + type: object + properties: + days: + type: integer + example: 7 + limit: + type: integer + example: 10 + used: + type: integer + example: 6 + remaining: + type: integer + example: 4 + restricted: + type: boolean + example: false + /blacklist: + get: + summary: Returns blacklisted items + description: Returns list of all blacklisted media + tags: + - settings + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 25 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: search + schema: + type: string + nullable: true + example: dune + - in: query + name: filter + schema: + type: string + enum: [all, manual, blacklistedTags] + default: manual + responses: + '200': + description: Blacklisted items returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + type: object + properties: + user: + $ref: '#/components/schemas/User' + createdAt: + type: string + example: 2024-04-21T01:55:44.000Z + id: + type: integer + example: 1 + mediaType: + type: string + example: movie + title: + type: string + example: Dune + tmdbid: + type: integer + example: 438631 + post: + summary: Add media to blacklist + tags: + - blacklist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Blacklist' + responses: + '201': + description: Item succesfully blacklisted + '412': + description: Item has already been blacklisted + /blacklist/{tmdbId}: + get: + summary: Get media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Blacklist details in JSON + delete: + summary: Remove media from blacklist + tags: + - blacklist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /watchlist: + post: + summary: Add media to watchlist + tags: + - watchlist + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + $ref: '#/components/schemas/Watchlist' + /watchlist/{tmdbId}: + delete: + summary: Delete watchlist item + description: Removes a watchlist item. + tags: + - watchlist + parameters: + - in: path + name: tmdbId + description: tmdbId ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed watchlist item + /user/{userId}/watchlist: + get: + summary: Get the Plex watchlist for a specific user + description: | + Retrieves a user's Plex Watchlist in a JSON object. + tags: + - users + - watchlist + parameters: + - in: path + name: userId + required: true + schema: + type: integer + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /user/{userId}/settings/main: + get: + summary: Get general settings for a user + description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + post: + summary: Update general settings for a user + description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettings' + /user/{userId}/settings/password: + get: + summary: Get password page informatiom + description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User password page information returned + content: + application/json: + schema: + type: object + properties: + hasPassword: + type: boolean + example: true + post: + summary: Update password for a user + description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + currentPassword: + type: string + nullable: true + newPassword: + type: string + required: + - newPassword + responses: + '204': + description: User password updated + /user/{userId}/settings/linked-accounts/plex: + post: + summary: Link the provided Plex account to the current user + description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + authToken: + type: string + required: + - authToken + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Plex account for a user + description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/linked-accounts/jellyfin: + post: + summary: Link the provided Jellyfin account to the current user + description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + example: 'Mr User' + password: + type: string + example: 'supersecret' + responses: + '204': + description: Linking account succeeded + '403': + description: Invalid credentials + '422': + description: Account already linked to a user + delete: + summary: Remove the linked Jellyfin account for a user + description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '204': + description: Unlinking account succeeded + '400': + description: Unlink request invalid + '404': + description: User does not exist + /user/{userId}/settings/notifications: + get: + summary: Get notification settings for a user + description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + post: + summary: Update notification settings for a user + description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + responses: + '200': + description: Updated user notification settings returned + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsNotifications' + /user/{userId}/settings/permissions: + get: + summary: Get permission settings for a user + description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: User permission settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + post: + summary: Update permission settings for a user + description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + required: + - permissions + responses: + '200': + description: Updated user general settings returned + content: + application/json: + schema: + type: object + properties: + permissions: + type: integer + example: 2 + /user/{userId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and recently watched media. + + Requires the `ADMIN` permission to fetch results for other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: integer + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + recentlyWatched: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + playCount: + type: integer + /search: + get: + summary: Search for movies, TV shows, or people + description: Returns a list of movies, TV shows, or people a JSON object. + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Mulan' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /search/keyword: + get: + summary: Search for keywords + description: Returns a list of TMDB keywords matching the search query + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'christmas' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Keyword' + /search/company: + get: + summary: Search for companies + description: Returns a list of TMDB companies matching the search query. (Will not return origin country) + tags: + - search + parameters: + - in: query + name: query + required: true + schema: + type: string + example: 'Disney' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/Company' + /discover/movies: + get: + summary: Discover movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: studio + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: primaryReleaseDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: primaryReleaseDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: certification + schema: + type: string + example: PG-13 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: G + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: PG-13 + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/genre/{genreId}: + get: + summary: Discover movies by genre + description: Returns a list of movies based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/language/{language}: + get: + summary: Discover movies by original language + description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/studio/{studioId}: + get: + summary: Discover movies by studio + description: Returns a list of movies based on the provided studio ID in a JSON object. + tags: + - search + parameters: + - in: path + name: studioId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + studio: + $ref: '#/components/schemas/ProductionCompany' + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/movies/upcoming: + get: + summary: Upcoming movies + description: Returns a list of movies in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/tv: + get: + summary: Discover TV shows + description: Returns a list of TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + - in: query + name: genre + schema: + type: string + example: 18 + - in: query + name: network + schema: + type: integer + example: 1 + - in: query + name: keywords + schema: + type: string + example: 1,2 + - in: query + name: excludeKeywords + schema: + type: string + example: 3,4 + description: Comma-separated list of keyword IDs to exclude from results + - in: query + name: sortBy + schema: + type: string + example: popularity.desc + - in: query + name: firstAirDateGte + schema: + type: string + example: 2022-01-01 + - in: query + name: firstAirDateLte + schema: + type: string + example: 2023-01-01 + - in: query + name: withRuntimeGte + schema: + type: integer + example: 60 + - in: query + name: withRuntimeLte + schema: + type: integer + example: 120 + - in: query + name: voteAverageGte + schema: + type: integer + example: 7 + - in: query + name: voteAverageLte + schema: + type: integer + example: 10 + - in: query + name: voteCountGte + schema: + type: integer + example: 7 + - in: query + name: voteCountLte + schema: + type: integer + example: 10 + - in: query + name: watchRegion + schema: + type: string + example: US + - in: query + name: watchProviders + schema: + type: string + example: 8|9 + - in: query + name: status + schema: + type: string + example: 3|4 + - in: query + name: certification + schema: + type: string + example: TV-14 + description: Exact certification to filter by (used when certificationMode is 'exact') + - in: query + name: certificationGte + schema: + type: string + example: TV-PG + description: Minimum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationLte + schema: + type: string + example: TV-MA + description: Maximum certification to filter by (used when certificationMode is 'range') + - in: query + name: certificationCountry + schema: + type: string + example: US + description: Country code for the certification system (e.g., US, GB, CA) + - in: query + name: certificationMode + schema: + type: string + enum: [exact, range] + example: exact + description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/language/{language}: + get: + summary: Discover TV shows by original language + description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. + tags: + - search + parameters: + - in: path + name: language + required: true + schema: + type: string + example: en + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + language: + $ref: '#/components/schemas/SpokenLanguage' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/genre/{genreId}: + get: + summary: Discover TV shows by genre + description: Returns a list of TV shows based on the provided genre ID in a JSON object. + tags: + - search + parameters: + - in: path + name: genreId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + genre: + $ref: '#/components/schemas/Genre' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/network/{networkId}: + get: + summary: Discover TV shows by network + description: Returns a list of TV shows based on the provided network ID in a JSON object. + tags: + - search + parameters: + - in: path + name: networkId + required: true + schema: + type: string + example: '1' + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + network: + $ref: '#/components/schemas/Network' + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/tv/upcoming: + get: + summary: Discover Upcoming TV shows + description: Returns a list of upcoming TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /discover/trending: + get: + summary: Trending movies and TV + description: Returns a list of movies and TV shows in a JSON object. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + anyOf: + - $ref: '#/components/schemas/MovieResult' + - $ref: '#/components/schemas/TvResult' + - $ref: '#/components/schemas/PersonResult' + /discover/keyword/{keywordId}/movies: + get: + summary: Get movies from keyword + description: Returns list of movies based on the provided keyword ID a JSON object. + tags: + - search + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 207317 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /discover/genreslider/movie: + get: + summary: Get genre slider data for movies + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/genreslider/tv: + get: + summary: Get genre slider data for TV series + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/watchlist: + get: + summary: Get the Plex watchlist. + tags: + - search + parameters: + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + responses: + '200': + description: Watchlist data returned + content: + application/json: + schema: + type: object + properties: + page: + type: integer + totalPages: + type: integer + totalResults: + type: integer + results: + type: array + items: + type: object + properties: + tmdbid: + type: integer + example: 1 + ratingKey: + type: string + type: + type: string + title: + type: string + /request: + get: + summary: Get all requests + description: | + Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. + + If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. + tags: + - request + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + approved, + available, + pending, + processing, + unavailable, + failed, + deleted, + completed, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: sortDirection + schema: + type: string + enum: [asc, desc] + nullable: true + default: desc + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + - in: query + name: mediaType + schema: + type: string + enum: [movie, tv, all] + nullable: true + default: all + responses: + '200': + description: Requests returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaRequest' + post: + summary: Create new request + description: | + Creates a new request with the provided media ID and type. The `REQUEST` permission is required. + + If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. + tags: + - request + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + example: movie + mediaId: + type: integer + example: 123 + tvdbid: + type: integer + example: 123 + seasons: + # oneOf: + # - type: array + # items: + # type: integer + # minimum: 0 + # - type: string + # enum: [all] + # TODO + type: string + enum: [all] + nullable: true + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + nullable: true + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + - mediaId + responses: + '201': + description: Succesfully created the request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/count: + get: + summary: Gets request counts + description: | + Returns the number of requests by status including pending, approved, available, and completed requests. + tags: + - request + responses: + '200': + description: Request counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + movie: + type: integer + tv: + type: integer + pending: + type: integer + approved: + type: integer + declined: + type: integer + processing: + type: integer + available: + type: integer + completed: + type: integer + /request/{requestId}: + get: + summary: Get MediaRequest + description: Returns a specific MediaRequest in a JSON object. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Succesfully returns request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + put: + summary: Update MediaRequest + description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + mediaType: + type: string + enum: [movie, tv] + seasons: + type: array + items: + type: integer + minimum: 0 + is4k: + type: boolean + example: false + serverid: + type: integer + profileid: + type: integer + rootFolder: + type: string + languageProfileid: + type: integer + userid: + type: integer + nullable: true + required: + - mediaType + responses: + '200': + description: Succesfully updated request + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + delete: + summary: Delete request + description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed request + /request/{requestId}/retry: + post: + summary: Retry failed request + description: | + Retries a request by resending requests to Sonarr or Radarr. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + responses: + '200': + description: Retry triggered + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /request/{requestId}/{status}: + post: + summary: Update a request's status + description: | + Updates a request's status to approved or declined. Also returns the request in a JSON object. + + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. + tags: + - request + parameters: + - in: path + name: requestId + description: Request ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [approve, decline] + responses: + '200': + description: Request status changed + content: + application/json: + schema: + $ref: '#/components/schemas/MediaRequest' + /movie/{movieId}: + get: + summary: Get movie details + description: Returns full movie details in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Movie details + content: + application/json: + schema: + $ref: '#/components/schemas/MovieDetails' + /movie/{movieId}/recommendations: + get: + summary: Get recommended movies + description: Returns list of recommended movies based on provided movie ID in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/similar: + get: + summary: Get similar movies + description: Returns list of similar movies based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of movies + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/MovieResult' + /movie/{movieId}/ratings: + get: + summary: Get movie ratings + description: Returns ratings based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + /movie/{movieId}/ratingscombined: + get: + summary: Get RT and IMDB movie ratings combined + description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + tags: + - movies + parameters: + - in: path + name: movieId + required: true + schema: + type: integer + example: 337401 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + rt: + type: object + properties: + title: + type: string + example: Mulan + year: + type: integer + example: 2020 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] + imdb: + type: object + properties: + title: + type: string + example: I am Legend + url: + type: string + example: 'https://www.imdb.com/title/tt0480249' + criticsScore: + type: integer + example: 6.5 + /tv/{tvId}: + get: + summary: Get TV details + description: Returns full TV details in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/TvDetails' + /tv/{tvId}/season/{seasonNumber}: + get: + summary: Get season details and episode list + description: Returns season details with a list of episodes in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: path + name: seasonNumber + required: true + schema: + type: integer + example: 123456 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: TV details + content: + application/json: + schema: + $ref: '#/components/schemas/Season' + /tv/{tvId}/recommendations: + get: + summary: Get recommended TV series + description: Returns list of recommended TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/similar: + get: + summary: Get similar TV series + description: Returns list of similar TV series based on the provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + - in: query + name: page + schema: + type: integer + example: 1 + default: 1 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: List of TV series + content: + application/json: + schema: + type: object + properties: + page: + type: integer + example: 1 + totalPages: + type: integer + example: 20 + totalResults: + type: integer + example: 200 + results: + type: array + items: + $ref: '#/components/schemas/TvResult' + /tv/{tvId}/ratings: + get: + summary: Get TV ratings + description: Returns ratings based on provided tvId in a JSON object. + tags: + - tv + parameters: + - in: path + name: tvId + required: true + schema: + type: integer + example: 76479 + responses: + '200': + description: Ratings returned + content: + application/json: + schema: + type: object + properties: + title: + type: string + example: The Boys + year: + type: integer + example: 2019 + url: + type: string + example: 'http://www.rottentomatoes.com/m/mulan_2020/' + criticsScore: + type: integer + example: 85 + criticsRating: + type: string + enum: ['Rotten', 'Fresh'] + /person/{personId}: + get: + summary: Get person details + description: Returns person details based on provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned person + content: + application/json: + schema: + $ref: '#/components/schemas/PersonDetails' + /person/{personId}/combined_credits: + get: + summary: Get combined credits + description: Returns the person's combined credits based on the provided personId in a JSON object. + tags: + - person + parameters: + - in: path + name: personId + required: true + schema: + type: integer + example: 287 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Returned combined credits + content: + application/json: + schema: + type: object + properties: + cast: + type: array + items: + $ref: '#/components/schemas/CreditCast' + crew: + type: array + items: + $ref: '#/components/schemas/CreditCrew' + id: + type: integer + /media: + get: + summary: Get media + description: Returns all media (can be filtered and limited) in a JSON object. + tags: + - media + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: + [ + all, + available, + partial, + allavailable, + processing, + pending, + deleted, + ] + - in: query + name: sort + schema: + type: string + enum: [added, modified, mediaAdded] + default: added + responses: + '200': + description: Returned media + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}: + delete: + summary: Delete media item + description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed media item + /media/{mediaId}/file: + delete: + summary: Delete media file + description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: query + name: is4k + description: Whether to remove from 4K service instance (true) or regular service instance (false) + required: false + example: false + schema: + type: boolean + responses: + '204': + description: Successfully removed media item + /media/{mediaId}/{status}: + post: + summary: Update media status + description: Updates a media item's status and returns the media in JSON format + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + - in: path + name: status + description: New status + required: true + example: available + schema: + type: string + enum: [available, partial, processing, pending, unknown, deleted] + requestBody: + content: + application/json: + schema: + type: object + properties: + is4k: + type: boolean + example: false + description: | + When true, updates the 4K status field (status4k). + When false or not provided, updates the regular status field (status). + This applies to all status values (available, partial, processing, pending, unknown). + responses: + '200': + description: Returned media + content: + application/json: + schema: + $ref: '#/components/schemas/MediaInfo' + /media/{mediaId}/watch_data: + get: + summary: Get watch data + description: | + Returns play count, play duration, and users who have watched the media. + + Requires the `ADMIN` permission. + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: '1' + schema: + type: string + responses: + '200': + description: Users + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + data4k: + type: object + properties: + playCount7Days: + type: integer + playCount30Days: + type: integer + playCount: + type: integer + users: + type: array + items: + $ref: '#/components/schemas/User' + /collection/{collectionId}: + get: + summary: Get collection details + description: Returns full collection details in a JSON object. + tags: + - collection + parameters: + - in: path + name: collectionId + required: true + schema: + type: integer + example: 537982 + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Collection details + content: + application/json: + schema: + $ref: '#/components/schemas/Collection' + /service/radarr: + get: + summary: Get non-sensitive Radarr server list + description: Returns a list of Radarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RadarrSettings' + /service/radarr/{radarrId}: + get: + summary: Get Radarr server quality profiles and root folders + description: Returns a Radarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: radarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/RadarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr: + get: + summary: Get non-sensitive Sonarr server list + description: Returns a list of Sonarr server IDs and names in a JSON object. + tags: + - service + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSettings' + /service/sonarr/{sonarrId}: + get: + summary: Get Sonarr server quality profiles and root folders + description: Returns a Sonarr server's quality profile and root folder details in a JSON object. + tags: + - service + parameters: + - in: path + name: sonarrId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: object + properties: + server: + $ref: '#/components/schemas/SonarrSettings' + profiles: + $ref: '#/components/schemas/ServiceProfile' + /service/sonarr/lookup/{tmdbId}: + get: + summary: Get series from Sonarr + description: Returns a list of series returned by searching for the name in Sonarr. + tags: + - service + parameters: + - in: path + name: tmdbId + required: true + schema: + type: integer + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSeries' + /regions: + get: + summary: Regions supported by TMDB + description: Returns a list of regions in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_3166_1: + type: string + example: US + english_name: + type: string + example: United States of America + /languages: + get: + summary: Languages supported by TMDB + description: Returns a list of languages in a JSON object. + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + iso_639_1: + type: string + example: en + english_name: + type: string + example: English + name: + type: string + example: English + /studio/{studioId}: + get: + summary: Get movie studio details + description: Returns movie studio details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: studioId + required: true + schema: + type: integer + example: 2 + responses: + '200': + description: Movie studio details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /network/{networkId}: + get: + summary: Get TV network details + description: Returns TV network details in a JSON object. + tags: + - tmdb + parameters: + - in: path + name: networkId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: TV network details + content: + application/json: + schema: + $ref: '#/components/schemas/ProductionCompany' + /genres/movie: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 10751 + name: + type: string + example: Family + /genres/tv: + get: + summary: Get list of official TMDB movie genres + description: Returns a list of genres in a JSON array. + tags: + - tmdb + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + example: 18 + name: + type: string + example: Drama + /backdrops: + get: + summary: Get backdrops of trending items + description: Returns a list of backdrop image paths in a JSON array. + security: [] + tags: + - tmdb + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: string + /issue: + get: + summary: Get all issues + description: | + Returns a list of issues in JSON format. + tags: + - issue + parameters: + - in: query + name: take + schema: + type: integer + nullable: true + example: 20 + - in: query + name: skip + schema: + type: integer + nullable: true + example: 0 + - in: query + name: sort + schema: + type: string + enum: [added, modified] + default: added + - in: query + name: filter + schema: + type: string + enum: [all, open, resolved] + default: open + - in: query + name: requestedBy + schema: + type: integer + nullable: true + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + type: object + properties: + pageInfo: + $ref: '#/components/schemas/PageInfo' + results: + type: array + items: + $ref: '#/components/schemas/Issue' + post: + summary: Create new issue + description: | + Creates a new issue + tags: + - issue + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + issueType: + type: integer + message: + type: string + mediaid: + type: integer + responses: + '201': + description: Succesfully created the issue + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + + /issue/count: + get: + summary: Gets issue counts + description: | + Returns the number of open and closed issues, as well as the number of issues of each type. + tags: + - issue + responses: + '200': + description: Issue counts returned + content: + application/json: + schema: + type: object + properties: + total: + type: integer + video: + type: integer + audio: + type: integer + subtitles: + type: integer + others: + type: integer + open: + type: integer + closed: + type: integer + /issue/{issueId}: + get: + summary: Get issue + description: | + Returns a single issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Issues returned + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + delete: + summary: Delete issue + description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue + /issue/{issueId}/comment: + post: + summary: Create a comment + description: | + Creates a comment and returns associated issue in JSON format. + tags: + - issue + parameters: + - in: path + name: issueId + required: true + schema: + type: integer + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + required: + - message + responses: + '200': + description: Issue returned with new comment + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /issueComment/{commentId}: + get: + summary: Get issue comment + description: | + Returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + responses: + '200': + description: Comment returned + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + put: + summary: Update issue comment + description: | + Updates and returns a single issue comment in JSON format. + tags: + - issue + parameters: + - in: path + name: commentId + required: true + schema: + type: string + example: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '200': + description: Comment updated + content: + application/json: + schema: + $ref: '#/components/schemas/IssueComment' + delete: + summary: Delete issue comment + description: | + Deletes an issue comment. Only users with `MANAGE_ISSUES` or the user who created the comment can perform this action. + tags: + - issue + parameters: + - in: path + name: commentId + description: Issue Comment ID + required: true + example: '1' + schema: + type: string + responses: + '204': + description: Succesfully removed issue comment + /issue/{issueId}/{status}: + post: + summary: Update an issue's status + description: | + Updates an issue's status to approved or declined. Also returns the issue in a JSON object. + + Requires the `MANAGE_ISSUES` permission or `ADMIN`. + tags: + - issue + parameters: + - in: path + name: issueId + description: Issue ID + required: true + schema: + type: string + example: '1' + - in: path + name: status + description: New status + required: true + schema: + type: string + enum: [open, resolved] + responses: + '200': + description: Issue status changed + content: + application/json: + schema: + $ref: '#/components/schemas/Issue' + /keyword/{keywordId}: + get: + summary: Get keyword + description: | + Returns a single keyword in JSON format. + tags: + - other + parameters: + - in: path + name: keywordId + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: Keyword returned (null if not found) + content: + application/json: + schema: + nullable: true + $ref: '#/components/schemas/Keyword' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: 'Unable to retrieve keyword data.' + /watchproviders/regions: + get: + summary: Get watch provider regions + description: | + Returns a list of all available watch provider regions. + tags: + - other + responses: + '200': + description: Watch provider regions returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderRegion' + /watchproviders/movies: + get: + summary: Get watch provider movies + description: | + Returns a list of all available watch providers for movies. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for movies returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /watchproviders/tv: + get: + summary: Get watch provider series + description: | + Returns a list of all available watch providers for series. + tags: + - other + parameters: + - in: query + name: watchRegion + required: true + schema: + type: string + example: US + responses: + '200': + description: Watch providers for series returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + /certifications/movie: + get: + summary: Get movie certifications + description: Returns list of movie certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: Movie certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve movie certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve movie certifications. + /certifications/tv: + get: + summary: Get TV certifications + description: Returns list of TV show certifications from TMDB. + tags: + - other + security: + - cookieAuth: [] + - apiKey: [] + responses: + '200': + description: TV certifications returned + content: + application/json: + schema: + $ref: '#/components/schemas/CertificationResponse' + '500': + description: Unable to retrieve TV certifications + content: + application/json: + schema: + type: object + properties: + status: + type: integer + example: 500 + message: + type: string + example: Unable to retrieve TV certifications. + /overrideRule: + get: + summary: Get override rules + description: Returns a list of all override rules with their conditions and settings + tags: + - overriderule + responses: + '200': + description: Override rules returned + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + post: + summary: Create override rule + description: Creates a new Override Rule from the request body. + tags: + - overriderule + responses: + '200': + description: 'Values were successfully created' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + /overrideRule/{ruleId}: + put: + summary: Update override rule + description: Updates an Override Rule from the request body. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OverrideRule' + delete: + summary: Delete override rule by ID + description: Deletes the override rule with the provided ruleId. + tags: + - overriderule + parameters: + - in: path + name: ruleId + required: true + schema: + type: integer + responses: + '200': + description: Override rule successfully deleted + content: + application/json: + schema: + $ref: '#/components/schemas/OverrideRule' +security: + - cookieAuth: [] + - apiKey: [] diff --git a/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache new file mode 100644 index 00000000..33cec807 --- /dev/null +++ b/app/src/main/seerr/templates/jvm-common/infrastructure/Serializer.kt.mustache @@ -0,0 +1,191 @@ +package {{packageName}}.infrastructure + +{{#moshi}} +import com.squareup.moshi.Moshi +{{#enumUnknownDefaultCase}} +import com.squareup.moshi.adapters.EnumJsonAdapter +{{/enumUnknownDefaultCase}} +{{^moshiCodeGen}} +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +{{/moshiCodeGen}} +{{/moshi}} +{{#gson}} +import com.google.gson.Gson +import com.google.gson.GsonBuilder +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +{{#kotlinx-datetime}} +import kotlin.time.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +{{/kotlinx-datetime}} +import java.util.UUID +{{/gson}} +{{#jackson}} +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +{{/jackson}} +{{#kotlinx_serialization}} +import java.math.BigDecimal +import java.math.BigInteger +{{^threetenbp}} +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.OffsetDateTime +{{/threetenbp}} +{{#threetenbp}} +import org.threeten.bp.LocalDate +import org.threeten.bp.LocalDateTime +import org.threeten.bp.OffsetDateTime +{{/threetenbp}} +import java.util.UUID +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonBuilder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.SerializersModuleBuilder +import java.net.URI +import java.net.URL +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +{{/kotlinx_serialization}} + +{{#nonPublicApi}}internal {{/nonPublicApi}}{{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}object Serializer { +{{#moshi}} + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshiBuilder: Moshi.Builder = Moshi.Builder() + .add(OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .add(InstantAdapter()) + .add(LocalDateAdapter()) + .add(LocalTimeAdapter()) + {{/kotlinx-datetime}} + .add(LocalDateTimeAdapter()) + .add(LocalDateAdapter()) + .add(UUIDAdapter()) + .add(ByteArrayAdapter()) + .add(URIAdapter()) + {{^moshiCodeGen}} + .add(KotlinJsonAdapterFactory()) + {{/moshiCodeGen}} + .add(BigDecimalAdapter()) + .add(BigIntegerAdapter()) + + @JvmStatic + {{^nonPublicApi}}{{#explicitApi}}public {{/explicitApi}}{{/nonPublicApi}}val moshi: Moshi by lazy { +{{#enumUnknownDefaultCase}} + SerializerHelper.addEnumUnknownDefaultCase(moshiBuilder) +{{/enumUnknownDefaultCase}} + moshiBuilder.build() + } +{{/moshi}} +{{#gson}} + @JvmStatic + val gsonBuilder: GsonBuilder = GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeAdapter()) + {{#kotlinx-datetime}} + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(LocalTime::class.java, LocalTimeAdapter()) + {{/kotlinx-datetime}} + .registerTypeAdapter(LocalDateTime::class.java, LocalDateTimeAdapter()) + .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) + .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + {{#generateOneOfAnyOfWrappers}} + {{#models}} + {{#model}} + {{^isEnum}} + {{^hasChildren}} + .registerTypeAdapterFactory({{modelPackage}}.{{{classname}}}.CustomTypeAdapterFactory()) + {{/hasChildren}} + {{/isEnum}} + {{/model}} + {{/models}} + {{/generateOneOfAnyOfWrappers}} + + @JvmStatic + val gson: Gson by lazy { + gsonBuilder.create() + } +{{/gson}} +{{#jackson}} + @JvmStatic + val jacksonObjectMapper: ObjectMapper = jacksonObjectMapper() + .findAndRegisterModules() + .setSerializationInclusion(JsonInclude.Include.NON_ABSENT) + {{#enumUnknownDefaultCase}} + .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE, true) + {{/enumUnknownDefaultCase}} + .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, {{failOnUnknownProperties}}) +{{/jackson}} +{{#kotlinx_serialization}} + private var isAdaptersInitialized = false + + @JvmStatic + val kotlinxSerializationAdapters: SerializersModule by lazy { + isAdaptersInitialized = true + SerializersModule { + contextual(BigDecimal::class, BigDecimalAdapter) + contextual(BigInteger::class, BigIntegerAdapter) + {{^kotlinx-datetime}} + contextual(LocalDate::class, LocalDateAdapter) + contextual(LocalDateTime::class, LocalDateTimeAdapter) + contextual(OffsetDateTime::class, OffsetDateTimeAdapter) + {{/kotlinx-datetime}} + contextual(UUID::class, UUIDAdapter) + contextual(AtomicInteger::class, AtomicIntegerAdapter) + contextual(AtomicLong::class, AtomicLongAdapter) + contextual(AtomicBoolean::class, AtomicBooleanAdapter) + contextual(URI::class, URIAdapter) + contextual(URL::class, URLAdapter) + contextual(StringBuilder::class, StringBuilderAdapter) + + apply(kotlinxSerializationAdaptersConfiguration) + } + } + + var kotlinxSerializationAdaptersConfiguration: SerializersModuleBuilder.() -> Unit = {} + set(value) { + check(!isAdaptersInitialized) { + "Cannot configure kotlinxSerializationAdaptersConfiguration after kotlinxSerializationAdapters has been initialized." + } + field = value + } + + private var isJsonInitialized = false + + @JvmStatic + val kotlinxSerializationJson: Json by lazy { + isJsonInitialized = true + Json { + serializersModule = kotlinxSerializationAdapters + encodeDefaults = true + ignoreUnknownKeys = true + isLenient = true + explicitNulls = false + + apply(kotlinxSerializationJsonConfiguration) + } + } + + var kotlinxSerializationJsonConfiguration: JsonBuilder.() -> Unit = {} + set(value) { + check(!isJsonInitialized) { + "Cannot configure kotlinxSerializationJsonConfiguration after kotlinxSerializationJson has been initialized." + } + field = value + } +{{/kotlinx_serialization}} +} diff --git a/app/src/test/java/android/text/TextUtils.java b/app/src/test/java/android/text/TextUtils.java new file mode 100644 index 00000000..959d7957 --- /dev/null +++ b/app/src/test/java/android/text/TextUtils.java @@ -0,0 +1,9 @@ +package android.text; + +// Mocks static class for non-instrumented unit tests + +public class TextUtils { + public static boolean isEmpty(CharSequence str) { + return str == null || str.length() == 0; + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt new file mode 100644 index 00000000..7af1bfce --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -0,0 +1,124 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.services.DisplayMode +import com.github.damontecres.wholphin.services.RefreshRateService +import org.junit.Assert +import org.junit.Test + +class TestDisplayModeChoice { + companion object { + val HD_60 = DisplayMode(0, 1920, 1080, 60f) + val HD_30 = DisplayMode(1, 1920, 1080, 30f) + val HD_24 = DisplayMode(2, 1920, 1080, 24f) + + val UHD_60 = DisplayMode(3, 3840, 2160, 60f) + val UHD_30 = DisplayMode(4, 3840, 2160, 30f) + val UHD_24 = DisplayMode(5, 3840, 2160, 24f) + + val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + } + + @Test + fun test1() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(3, result?.modeId) + } + + @Test + fun test2() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 60f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(0, result?.modeId) + } + + @Test + fun test3() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(4, result?.modeId) + } + + @Test + fun test4() { + val streamWidth = 1920 + val streamHeight = 804 + val streamRealFrameRate = 30f + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals(1, result?.modeId) + } + + @Test + fun testFraction() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 30f + + val displayModes = + listOf( + DisplayMode(0, 1920, 1080, 59.940f), + DisplayMode(1, 1920, 1080, 60f), +// DisplayMode(2, 1920, 1080, 29.970f), + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 29.970f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(0, result?.modeId) + + val result2 = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = 24f, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(1, result2?.modeId) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt new file mode 100644 index 00000000..e41bf292 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestStreamChoiceService.kt @@ -0,0 +1,843 @@ +package com.github.damontecres.wholphin.test + +import androidx.lifecycle.MutableLiveData +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.ItemPlayback +import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.StreamChoiceService +import io.mockk.every +import io.mockk.mockk +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.MediaStream +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.SubtitlePlaybackMode +import org.jellyfin.sdk.model.api.UserDto +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceBasic( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.NONE, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + TestInput( + null, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.DISABLED), + ), + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.UNSPECIFIED), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceDefault( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + userSubtitleLang = null, + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", true), + ), + userSubtitleLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + 0, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + 1, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + plc = plc(subtitlesDisabled = true), + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceSmart( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitleLang = "spa"), + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "spa", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "spa", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", true), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "spa", + userAudioLang = null, + ), + TestInput( + 1, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", false), + subtitle(1, "eng", true), + subtitle(2, "spa", false), + ), + streamAudioLang = "eng", + userSubtitleLang = "eng", + userAudioLang = null, + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForced( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = 1), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitleLang = "eng"), + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + plc = plc(subtitlesDisabled = true), + ), + TestInput( + 0, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.ONLY_FORCED, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + TestInput( + 1, + SubtitlePlaybackMode.ONLY_FORCED, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "eng", forced = false), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + ), + ) + } +} + +@RunWith(Parameterized::class) +class TestStreamChoiceServiceMultipleChoices( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + TestInput( + 0, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", false), + ), + ), + TestInput( + 2, + SubtitlePlaybackMode.ALWAYS, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userAudioLang = null, + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = null, + userAudioLang = null, + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = null, + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "eng", + userAudioLang = null, + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = true), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "eng", + userAudioLang = "eng", + streamAudioLang = "eng", + ), + TestInput( + null, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "eng", + streamAudioLang = "spa", + ), + TestInput( + 0, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "spa", forced = true, default = false), + subtitle(1, "spa", false), + subtitle(2, "spa", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "eng", + streamAudioLang = "eng", + ), + TestInput( + 2, + SubtitlePlaybackMode.SMART, + subtitles = + listOf( + subtitle(0, "spa", forced = true, default = false), + subtitle(1, "spa", false), + subtitle(2, "spa", default = true), + ), + userSubtitleLang = "spa", + userAudioLang = "", + streamAudioLang = "eng", + ), + TestInput( + 2, + SubtitlePlaybackMode.DEFAULT, + subtitles = + listOf( + subtitle(0, "eng", forced = true, default = false), + subtitle(1, "eng", false), + subtitle(2, "eng", default = true), + ), + userSubtitleLang = null, + userAudioLang = null, + ), + ) + } +} + +/** + * Tests for client-side "Only Forced" override (TrackIndex.ONLY_FORCED). + * This tests the findForcedTrack function with user subtitle language preference. + */ +@RunWith(Parameterized::class) +class TestStreamChoiceServiceOnlyForcedClientOverride( + val input: TestInput, +) { + @Test + fun test() { + runTest(input) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{index}: {0}") + fun data(): Collection = + listOf( + // Test 1: Prefer user's subtitle language preference for forced tracks + TestInput( + expectedIndex = 1, // spa forced track (matches user pref) + userSubtitleMode = null, + userSubtitleLang = "spa", + subtitles = + listOf( + subtitle(0, "eng", forced = true), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 2: User subtitle preference matches Signs track via title (not forced flag) + TestInput( + expectedIndex = 0, // eng signs track via title detection + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "jpn", // different from subtitle pref + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 3: Falls back to audio-matching forced when no preference match + TestInput( + expectedIndex = 0, // eng forced (audio match, step 2) + userSubtitleMode = null, + userSubtitleLang = "spa", // no spa forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = true), // matches audio + subtitle(1, "und", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 4: Falls back to audio-matching signs when user pref has no match + TestInput( + expectedIndex = 0, // eng signs (audio match fallback) + userSubtitleMode = null, + userSubtitleLang = "fre", // user prefers French, no French forced exists + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), // matches audio + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 5: Use audio language for signs/songs when NO subtitle preference + TestInput( + expectedIndex = 0, // eng signs track matching audio + userSubtitleMode = null, + userSubtitleLang = null, // no preference + subtitles = + listOf( + subtitle(0, "eng", forced = false, title = "Signs & Songs"), + subtitle(1, "spa", forced = true), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 6: Unknown language forced track with no preference + TestInput( + expectedIndex = 0, // unknown forced track + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, null, forced = true), // unknown/null language + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 7: Unknown language forced track when no audio match + TestInput( + expectedIndex = 0, // unknown forced track (step 3) + userSubtitleMode = null, + userSubtitleLang = null, + subtitles = + listOf( + subtitle(0, "und", forced = true), // unknown language forced + subtitle(1, "spa", forced = false), + ), + streamAudioLang = "eng", // no eng tracks exist + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + // Test 8: No matching forced track returns null (not irrelevant language) + TestInput( + expectedIndex = null, // no forced track matches - return null instead of wrong language + userSubtitleMode = null, + userSubtitleLang = "eng", + subtitles = + listOf( + subtitle(0, "chi", forced = true), // Chinese forced - wrong language + subtitle(1, "spa", forced = true), // Spanish forced - wrong language + ), + streamAudioLang = "eng", // audio is English, no English forced + itemPlayback = itemPlayback(subtitleIndex = TrackIndex.ONLY_FORCED), + ), + ) + } +} + +data class TestInput( + val expectedIndex: Int?, + val userSubtitleMode: SubtitlePlaybackMode?, + val userAudioLang: String? = "eng", + val userSubtitleLang: String? = "eng", + val streamAudioLang: String? = "eng", + val subtitles: List, + val itemPlayback: ItemPlayback? = null, + val plc: PlaybackLanguageChoice? = null, +) { + override fun toString(): String = "test(mode=$userSubtitleMode, subtitles=${subtitles.map { it.toShortString() }})" +} + +private fun MediaStream.toShortString(): String = "$type(index=$index, lang=$language, default=$isDefault, forced=$isForced)" + +private fun serverRepo( + audioLang: String?, + subtitleMode: SubtitlePlaybackMode?, + subtitleLang: String?, +): ServerRepository { + val mocked = mockk() + every { mocked.currentUserDto } returns + MutableLiveData( + UserDto( + id = UUID.randomUUID(), + hasPassword = true, + hasConfiguredPassword = true, + hasConfiguredEasyPassword = true, + configuration = + DefaultUserConfiguration.copy( + audioLanguagePreference = audioLang, + subtitleMode = subtitleMode ?: SubtitlePlaybackMode.DEFAULT, + subtitleLanguagePreference = subtitleLang, + ), + ), + ) + return mocked +} + +private fun runTest(input: TestInput) { + val service = + StreamChoiceService( + serverRepo(input.userAudioLang, input.userSubtitleMode, input.userSubtitleLang), + mockk(), + ) + val result = + service.chooseSubtitleStream( + audioStreamLang = input.streamAudioLang, + candidates = input.subtitles, + itemPlayback = input.itemPlayback, + playbackLanguageChoice = input.plc, + prefs = UserPreferences(AppPreferences.getDefaultInstance()), + ) + Assert.assertEquals(input.expectedIndex, result?.index) +} + +fun subtitle( + index: Int, + lang: String?, + default: Boolean = false, + forced: Boolean = false, + title: String? = null, +): MediaStream = + MediaStream( + type = MediaStreamType.SUBTITLE, + language = lang, + isDefault = default, + isForced = forced, + isHearingImpaired = false, + isInterlaced = false, + index = index, + isExternal = false, + isTextSubtitleStream = true, + supportsExternalStream = true, + title = title, + ) + +private fun itemPlayback( + audioIndex: Int = TrackIndex.UNSPECIFIED, + subtitleIndex: Int = TrackIndex.UNSPECIFIED, +): ItemPlayback = + ItemPlayback( + rowId = 1, + userId = 1, + itemId = UUID.randomUUID(), + sourceId = UUID.randomUUID(), + audioIndex = audioIndex, + subtitleIndex = subtitleIndex, + ) + +private fun plc( + audioLang: String? = null, + subtitleLang: String? = null, + subtitlesDisabled: Boolean? = if (subtitleLang != null) false else null, +): PlaybackLanguageChoice = + PlaybackLanguageChoice( + userId = 1, + seriesId = UUID.randomUUID(), + audioLanguage = audioLang, + subtitleLanguage = subtitleLang, + subtitlesDisabled = subtitlesDisabled, + ) diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt new file mode 100644 index 00000000..cad496ca --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestTrackSelection.kt @@ -0,0 +1,591 @@ +package com.github.damontecres.wholphin.test + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import com.github.damontecres.wholphin.data.model.TrackIndex +import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.ui.playback.TrackSelectionUtils +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.junit.Assert +import org.junit.Test +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestTrackSelection { + /** + * Builds the tracks for the `embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("$it") + .setSampleMimeType("text/default") + .build() + } + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `no_embedded_subs.json` for the given backend + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildNoEmbeddedTracks(backend: PlayerBackend): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (1..3).map { + Format + .Builder() + .setId("$it:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (1..3).map { + Format + .Builder() + .setId("${it + 3}:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("7:e:4") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + // ExoPlayer + val video = + Format + .Builder() + .setId("0:1") + .setSampleMimeType("video/default") + .build() + val audios = + (2..4).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("audio/default") + .build() + } + val subtitles = + (5..7).map { + Format + .Builder() + .setId("0:$it") + .setSampleMimeType("text/default") + .build() + } + + listOf( + Format + .Builder() + .setId("1:e:0") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + /** + * Builds the tracks for the `external_subs.json` for the given backend. + * + * Must supply the desired subtitle index because ExoPlayer uses it. + * + * Note: This is manual based on observation & code review of the playback for that file + */ + private fun buildExternalTracks( + backend: PlayerBackend, + selectedIndex: Int, + ): Tracks { + val formats = + if (backend == PlayerBackend.MPV) { + val video = + Format + .Builder() + .setId("1:1") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("2:1") + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("3:e:2") + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } else { + val video = + Format + .Builder() + .setId("0:2") + .setSampleMimeType("video/default") + .build() + val audios = + listOf( + Format + .Builder() + .setId("0:1") + .setSampleMimeType("audio/default") + .build(), + ) + val subtitles = + listOf( + Format + .Builder() + .setId("0:3") // Embedded + .setSampleMimeType("text/default") + .build(), + Format + .Builder() + .setId("1:e:$selectedIndex") // External + .setSampleMimeType("text/default") + .build(), + ) + (listOf(video) + audios + subtitles) + } + val groups = + formats + .map { TrackGroup(it) } + .map { Tracks.Group(it, false, intArrayOf(C.FORMAT_HANDLED), booleanArrayOf(false)) } + return Tracks(groups) + } + + private fun TrackSelectionParameters.getAudioOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_AUDIO) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + private fun TrackSelectionParameters.getSubtitleOverride(): Format? { + this.overrides.forEach { (trackGroup, trackSelectionOverride) -> + if (trackGroup.type == C.TRACK_TYPE_TEXT) { + return trackGroup.getFormat(trackSelectionOverride.trackIndices.first()) + } + } + return null + } + + @Test + fun `test MPV embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("4:1", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer embedded`() { + val resource = javaClass.classLoader?.getResource("embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(7, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 4, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("5", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 6, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("3", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 1, + subtitleIndex = TrackIndex.DISABLED, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals(null, result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.MPV) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("1:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("2:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("7:e:4", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test ExoPlayer no embedded`() { + val resource = javaClass.classLoader?.getResource("no_embedded_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildNoEmbeddedTracks(PlayerBackend.EXO_PLAYER) + Assert.assertEquals(5, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 2, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.bothSelected) + Assert.assertEquals("0:2", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("1:e:0", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + } + + @Test + fun `test MPV external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + val tracks = buildExternalTracks(PlayerBackend.MPV, 0) + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals("0:1", result.trackSelectionParameters.getAudioOverride()?.id) + Assert.assertEquals("3:e:2", result.trackSelectionParameters.getSubtitleOverride()?.id) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.MPV, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "2:1", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + @Test + fun `test ExoPlayer external`() { + val resource = javaClass.classLoader?.getResource("external_subs.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + val source = Json.decodeFromString(fileContents) + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 0).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 0, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:0", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + + // Select embedded subtitles + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 5, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "0:3", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + + buildExternalTracks(PlayerBackend.EXO_PLAYER, 2).also { tracks -> + Assert.assertEquals(6, source.mediaStreams?.size) + + val trackSelectionParameters = TrackSelectionParameters.Builder().build() + + TrackSelectionUtils + .createTrackSelections( + trackSelectionParams = trackSelectionParameters, + tracks = tracks, + playerBackend = PlayerBackend.EXO_PLAYER, + supportsDirectPlay = true, + audioIndex = 3, + subtitleIndex = 2, + source = source, + ).also { result -> + Assert.assertTrue(result.audioSelected) + Assert.assertTrue(result.subtitleSelected) + Assert.assertEquals( + "0:1", + result.trackSelectionParameters.getAudioOverride()?.id, + ) + Assert.assertEquals( + "1:e:2", + result.trackSelectionParameters.getSubtitleOverride()?.id, + ) + } + } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt new file mode 100644 index 00000000..2c3648a2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManager.kt @@ -0,0 +1,768 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Bundle +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * Unit tests for [VoiceInputManager] state machine logic. + * + * Uses Robolectric to provide Android framework classes (Intent, Bundle) + * and Mockk to mock [SpeechRecognizer] and simulate recognition callbacks + * without requiring a real microphone or emulator. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManager { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var listenerSlot: CapturingSlot + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var network: Network + private lateinit var networkCapabilities: NetworkCapabilities + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + // Mock Activity + activity = mockk(relaxed = true) + + // Mock AudioManager + audioManager = mockk(relaxed = true) + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + + // Mock ConnectivityManager with network available by default + connectivityManager = mockk(relaxed = true) + network = mockk() + networkCapabilities = mockk() + every { connectivityManager.activeNetwork } returns network + every { connectivityManager.getNetworkCapabilities(network) } returns networkCapabilities + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + // Mock SpeechRecognizer instance + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + + // Capture the RecognitionListener when setRecognitionListener is called + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + // Mock static factory method + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + // Create the manager under test + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + // ========== Test Case 1: Initial State ========== + + @Test + fun `initial state is Idle`() { + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `initial soundLevel is zero`() { + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `initial partialResult is empty`() { + assertEquals("", manager.partialResult.value) + } + + // ========== Test Case 2: Start Listening ========== + + @Test + fun `startListening transitions state to Starting`() { + manager.startListening() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `onReadyForSpeech transitions state to Listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `startListening creates SpeechRecognizer`() { + manager.startListening() + idleMainLooper() + + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening sets recognition listener`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.setRecognitionListener(any()) } + assertTrue(listenerSlot.isCaptured) + } + + @Test + fun `startListening calls recognizer startListening`() { + manager.startListening() + idleMainLooper() + + verify { speechRecognizer.startListening(any()) } + } + + @Test + fun `startListening resets partialResult`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + manager.stopListening() + idleMainLooper() + + manager.startListening() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `startListening is ignored when already listening`() { + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `startListening is ignored when in Starting state`() { + manager.startListening() + idleMainLooper() + assertEquals(VoiceInputState.Starting, manager.state.value) + + manager.startListening() // Should be ignored + idleMainLooper() + + // Only one recognizer should be created + verify(exactly = 1) { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + // ========== Test Case 3: Permission Denied ========== + + @Test + fun `onPermissionDenied transitions to Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onPermissionDenied sets correct error resource`() { + manager.onPermissionDenied() + idleMainLooper() + + val errorState = manager.state.value as VoiceInputState.Error + assertEquals(R.string.voice_error_permissions, errorState.messageResId) + } + + // ========== Test Case 4: Result Success ========== + + @Test + fun `onResults transitions to Result state with correct text`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("hello world")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Result) + assertEquals("hello world", (manager.state.value as VoiceInputState.Result).text) + } + + @Test + fun `onResults resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onResults with empty text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle("")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with null bundle transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(null) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onResults with blank text transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onResults(createResultsBundle(" ")) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + // ========== Test Case 5: Error Handling ========== + + @Test + fun `onError transitions to Error state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + } + + @Test + fun `onError maps ERROR_NETWORK to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_NO_MATCH to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NO_MATCH) + idleMainLooper() + + assertEquals(R.string.voice_error_no_match, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_AUDIO to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_AUDIO) + idleMainLooper() + + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SERVER to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SERVER) + idleMainLooper() + + assertEquals(R.string.voice_error_server, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_CLIENT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_CLIENT) + idleMainLooper() + + assertEquals(R.string.voice_error_client, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_SPEECH_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_speech_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError ERROR_SPEECH_TIMEOUT is retryable`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_SPEECH_TIMEOUT) + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `onError maps ERROR_NETWORK_TIMEOUT to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK_TIMEOUT) + idleMainLooper() + + assertEquals(R.string.voice_error_network_timeout, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_RECOGNIZER_BUSY to correct resource after retry exhausted`() { + manager.startListening() + idleMainLooper() + + // First BUSY triggers auto-retry + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(350)) + + // Second BUSY exhausts retry count and shows error + capturedListener.onError(SpeechRecognizer.ERROR_RECOGNIZER_BUSY) + idleMainLooper() + + assertEquals(R.string.voice_error_busy, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps ERROR_INSUFFICIENT_PERMISSIONS to correct resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS) + idleMainLooper() + + assertEquals(R.string.voice_error_permissions, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError maps unknown error to unknown resource`() { + manager.startListening() + idleMainLooper() + + capturedListener.onError(999) // Unknown error code + idleMainLooper() + + assertEquals(R.string.voice_error_unknown, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `onError resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + capturedListener.onError(SpeechRecognizer.ERROR_NETWORK) + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + // ========== Test Case 6: Cleanup/Lifecycle ========== + + @Test + fun `cleanup resets state to Idle`() { + manager.startListening() + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `cleanup calls cancel on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.cancel() } + } + + @Test + fun `cleanup calls destroy on recognizer`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { speechRecognizer.destroy() } + } + + @Test + fun `cleanup resets soundLevel to zero`() { + manager.startListening() + idleMainLooper() + capturedListener.onRmsChanged(5f) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `cleanup resets partialResult to empty`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("partial")) + idleMainLooper() + + manager.close() + idleMainLooper() + + assertEquals("", manager.partialResult.value) + } + + @Test + fun `stopListening triggers cleanup`() { + manager.startListening() + idleMainLooper() + + manager.stopListening() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + verify { speechRecognizer.cancel() } + verify { speechRecognizer.destroy() } + } + + // ========== Additional Coverage ========== + + @Test + fun `onEndOfSpeech transitions to Processing state`() { + manager.startListening() + idleMainLooper() + + capturedListener.onEndOfSpeech() + idleMainLooper() + + assertEquals(VoiceInputState.Processing, manager.state.value) + } + + @Test + fun `onRmsChanged updates soundLevel with normalized value`() { + manager.startListening() + idleMainLooper() + + // RMS normalization: (rmsdB - (-2)) / (10 - (-2)) clamped to [0, 1] + // For rmsdB = 4: (4 - (-2)) / 12 = 6/12 = 0.5 + capturedListener.onRmsChanged(4f) + idleMainLooper() + + assertEquals(0.5f, manager.soundLevel.value, 0.01f) + } + + @Test + fun `onRmsChanged clamps high values to 1`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(20f) // Above max + idleMainLooper() + + assertEquals(1f, manager.soundLevel.value) + } + + @Test + fun `onRmsChanged clamps low values to 0`() { + manager.startListening() + idleMainLooper() + + capturedListener.onRmsChanged(-10f) // Below min + idleMainLooper() + + assertEquals(0f, manager.soundLevel.value) + } + + @Test + fun `onPartialResults updates partialResult`() { + manager.startListening() + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle("hello")) + idleMainLooper() + + assertEquals("hello", manager.partialResult.value) + } + + @Test + fun `onPartialResults ignores blank text`() { + manager.startListening() + idleMainLooper() + capturedListener.onPartialResults(createResultsBundle("first")) + idleMainLooper() + + capturedListener.onPartialResults(createResultsBundle(" ")) + idleMainLooper() + + assertEquals("first", manager.partialResult.value) + } + + @Test + fun `acknowledge resets state to Idle`() { + manager.startListening() + idleMainLooper() + capturedListener.onResults(createResultsBundle("test")) + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `acknowledge works from Error state`() { + manager.onPermissionDenied() + idleMainLooper() + + manager.acknowledge() + idleMainLooper() + + assertEquals(VoiceInputState.Idle, manager.state.value) + } + + @Test + fun `onPermissionGranted calls startListening`() { + manager.onPermissionGranted() + idleMainLooper() + + assertEquals(VoiceInputState.Starting, manager.state.value) + verify { SpeechRecognizer.createSpeechRecognizer(activity) } + } + + @Test + fun `callbacks from previous recognizer are ignored after cleanup`() { + // Create two different mock recognizers to simulate real behavior + val firstRecognizer = mockk(relaxed = true) + val secondRecognizer = mockk(relaxed = true) + val firstListenerSlot = slot() + val secondListenerSlot = slot() + + every { firstRecognizer.setRecognitionListener(capture(firstListenerSlot)) } just Runs + every { secondRecognizer.setRecognitionListener(capture(secondListenerSlot)) } just Runs + + // Return different recognizers for each call + every { SpeechRecognizer.createSpeechRecognizer(activity) } returnsMany listOf(firstRecognizer, secondRecognizer) + + manager.startListening() + idleMainLooper() + val firstListener = firstListenerSlot.captured + + manager.close() + idleMainLooper() + manager.startListening() + idleMainLooper() + + // Simulate callback from the old (zombie) recognizer + firstListener.onResults(createResultsBundle("zombie result")) + idleMainLooper() + + // State should remain Starting (from the second startListening), not Result + assertEquals(VoiceInputState.Starting, manager.state.value) + } + + @Test + fun `isAvailable returns mocked value`() { + assertTrue(manager.isAvailable) + } + + // ========== Test Case: Network Fast-Fail ========== + + @Test + fun `startListening fails immediately when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no network capabilities`() { + every { connectivityManager.getNetworkCapabilities(network) } returns null + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `startListening fails immediately when no internet capability`() { + every { networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns false + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_network, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `network error from fast-fail is retryable`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when no network`() { + every { connectivityManager.activeNetwork } returns null + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + // ========== Test Case: Audio Focus ========== + + @Test + fun `startListening fails when audio focus not granted`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue(manager.state.value is VoiceInputState.Error) + assertEquals(R.string.voice_error_audio, (manager.state.value as VoiceInputState.Error).messageResId) + } + + @Test + fun `audio focus error is retryable`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + assertTrue((manager.state.value as VoiceInputState.Error).isRetryable) + } + + @Test + fun `startListening does not create recognizer when audio focus denied`() { + every { audioManager.requestAudioFocus(any()) } returns AudioManager.AUDIOFOCUS_REQUEST_FAILED + + manager.startListening() + idleMainLooper() + + verify(exactly = 0) { SpeechRecognizer.createSpeechRecognizer(any()) } + } + + @Test + fun `audio focus is abandoned when recognizer is destroyed`() { + manager.startListening() + idleMainLooper() + + manager.close() + + verify { audioManager.abandonAudioFocusRequest(any()) } + } + + @Test + fun `startListening requests audio focus`() { + manager.startListening() + idleMainLooper() + + verify { audioManager.requestAudioFocus(any()) } + } + + // ========== Helper Functions ========== + + private fun createResultsBundle(text: String): Bundle = + Bundle().apply { + putStringArrayList( + SpeechRecognizer.RESULTS_RECOGNITION, + arrayListOf(text), + ) + } +} + +private typealias CapturingSlot = io.mockk.CapturingSlot diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt new file mode 100644 index 00000000..c046eea2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestVoiceInputManagerAutoFocus.kt @@ -0,0 +1,132 @@ +package com.github.damontecres.wholphin.test + +import android.app.Activity +import android.content.Context +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Looper +import android.speech.RecognitionListener +import android.speech.SpeechRecognizer +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.components.VoiceInputState +import io.mockk.CapturingSlot +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TestVoiceInputManagerAutoFocus { + private lateinit var activity: Activity + private lateinit var speechRecognizer: SpeechRecognizer + private lateinit var manager: VoiceInputManager + private lateinit var audioManager: AudioManager + private lateinit var connectivityManager: ConnectivityManager + private lateinit var listenerSlot: CapturingSlot + + // We need to capture the OnAudioFocusChangeListener passed to AudioFocusRequest + private val focusRequestSlot = slot() + + private val capturedListener: RecognitionListener + get() = listenerSlot.captured + + private fun idleMainLooper() = shadowOf(Looper.getMainLooper()).idle() + + @Before + fun setup() { + activity = mockk(relaxed = true) + audioManager = mockk(relaxed = true) + connectivityManager = mockk(relaxed = true) + + // Mock network availability + val mockNetwork = mockk() + val mockCapabilities = mockk() + every { connectivityManager.activeNetwork } returns mockNetwork + every { connectivityManager.getNetworkCapabilities(mockNetwork) } returns mockCapabilities + every { mockCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } returns true + + // Capture the AudioFocusRequest built by the specific line in VoiceInputManager + every { audioManager.requestAudioFocus(capture(focusRequestSlot)) } returns AudioManager.AUDIOFOCUS_REQUEST_GRANTED + every { activity.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + every { activity.getSystemService(Context.CONNECTIVITY_SERVICE) } returns connectivityManager + + speechRecognizer = mockk(relaxed = true) + listenerSlot = slot() + every { speechRecognizer.setRecognitionListener(capture(listenerSlot)) } just Runs + + mockkStatic(SpeechRecognizer::class) + every { SpeechRecognizer.createSpeechRecognizer(activity) } returns speechRecognizer + every { SpeechRecognizer.isRecognitionAvailable(activity) } returns true + + manager = VoiceInputManager(activity) + } + + @After + fun teardown() { + unmockkStatic(SpeechRecognizer::class) + } + + @Test + fun `transient audio focus loss is ignored`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + assertEquals(VoiceInputState.Listening, manager.state.value) + + // Verify requestAudioFocus was called + assertTrue(focusRequestSlot.isCaptured) + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS_TRANSIENT) + + idleMainLooper() + + // Assert state is STILL Listening (Logic correctly ignores it) + assertEquals(VoiceInputState.Listening, manager.state.value) + } + + @Test + fun `permanent audio focus loss stops listening`() { + // Start listening + manager.startListening() + idleMainLooper() + capturedListener.onReadyForSpeech(null) + idleMainLooper() + + // Get listener via reflection since AudioFocusRequest doesn't expose it + val field = VoiceInputManager::class.java.getDeclaredField("audioFocusListener") + field.isAccessible = true + val listener = field.get(manager) as AudioManager.OnAudioFocusChangeListener + + // Invoke onAudioFocusChange directly on the listener + listener.onAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS) + + idleMainLooper() + + // Assert state is NOW Idle (Logic correctly stops) + assertEquals(VoiceInputState.Idle, manager.state.value) + } +} diff --git a/app/src/test/resources/embedded_subs.json b/app/src/test/resources/embedded_subs.json new file mode 100644 index 00000000..f24aeefc --- /dev/null +++ b/app/src/test/resources/embedded_subs.json @@ -0,0 +1,235 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "JAP Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "JAP Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR - Portuguese - Default - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "por", + "TimeBase": "1/1000", + "Title": "ptBR FORCED", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "ptBR FORCED - Portuguese - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": true, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "ass", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "enUS", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "enUS - English - ASS", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 6, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "DefaultSubtitleStreamIndex": 6, + "HasSegments": true +} diff --git a/app/src/test/resources/external_subs.json b/app/src/test/resources/external_subs.json new file mode 100644 index 00000000..9f6a4b05 --- /dev/null +++ b/app/src/test/resources/external_subs.json @@ -0,0 +1,208 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 2147179830, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 12793200000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "0", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "0 - English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 1, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "English - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 2, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "h264", + "ColorSpace": "bt709", + "ColorTransfer": "bt709", + "ColorPrimaries": "bt709", + "TimeBase": "1/1000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 13427007, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1920, + "AverageFrameRate": 23.976025, + "RealFrameRate": 23.976025, + "ReferenceFrameRate": 23.976025, + "Profile": "High", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 40, + "IsAnamorphic": false + }, + { + "Codec": "subrip", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Undefined - Default - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 14067007, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 3, + "DefaultSubtitleStreamIndex": 0, + "HasSegments": true +} diff --git a/app/src/test/resources/no_embedded_subs.json b/app/src/test/resources/no_embedded_subs.json new file mode 100644 index 00000000..be4e3051 --- /dev/null +++ b/app/src/test/resources/no_embedded_subs.json @@ -0,0 +1,178 @@ +{ + "Protocol": "File", + "Id": "", + "Path": "", + "Type": "Default", + "Container": "mkv", + "Size": 651217902, + "Name": "", + "IsRemote": false, + "ETag": "", + "RunTimeTicks": 14919360000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Spanish - SUBRIP - External", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 0, + "IsExternal": true, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Path": "", + "Level": 0 + }, + { + "Codec": "hevc", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p - HEVC - SDR", + "IsInterlaced": false, + "IsAVC": false, + "BitRate": 3491934, + "BitDepth": 10, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 1080, + "Width": 1448, + "AverageFrameRate": 23.809525, + "RealFrameRate": 23.809525, + "ReferenceFrameRate": 23.809525, + "Profile": "Main 10", + "Type": "Video", + "AspectRatio": "4:3", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p10le", + "Level": 120, + "IsAnamorphic": false + }, + { + "Codec": "opus", + "Language": "jpn", + "TimeBase": "1/1000", + "Title": "Stereo (Opus 112Kbps)", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Stereo (Opus 112Kbps) - Japanese", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 101618, + "Channels": 2, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "aac", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - AAC - Stereo", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "stereo", + "BitRate": 249225, + "Channels": 2, + "SampleRate": 44100, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Profile": "LC", + "Type": "Audio", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "eac3", + "Language": "por", + "TimeBase": "1/1000", + "Title": "", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "Portuguese - Dolby Digital+ - 5.1", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 640000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 4482777, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 2, + "HasSegments": true +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7a5ffa42..6ed6b153 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,17 +1,23 @@ [versions] -aboutLibraries = "13.1.0" +aboutLibraries = "13.2.1" acra = "5.13.1" agp = "8.13.2" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" +hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" -kotlin = "2.2.21" +hiltWork = "1.3.0" +kotlin = "2.3.0" +kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2025.12.01" -multiplatformMarkdownRenderer = "0.38.1" +mockk = "1.14.7" +robolectric = "4.14.1" +multiplatformMarkdownRenderer = "0.39.0" +okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" @@ -25,15 +31,18 @@ jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" -protobuf = "0.9.5" +protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.9.0" protobuf-javalite = "4.33.2" hilt = "2.57.2" room = "2.8.4" preferenceKtx = "1.2.1" +tvprovider = "1.1.0" +workRuntimeKtx = "2.11.0" paletteKtx = "1.0.0" -assMedia = "0.4.0-alpha01" +openapi-generator = "7.18.0" +assMedia = "0.4.0-beta01" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -54,19 +63,29 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime-android" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } +androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "autoServiceKsp" } desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } +mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } +robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } +okhttp = { module = "com.squareup.okhttp3:okhttp" } +okhttp-bom = { module = "com.squareup.okhttp3:okhttp-bom", version.ref = "okhttpBom" } programguide = { module = "io.github.oleksandrbalan:programguide", version.ref = "programguide" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobuf-javalite" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } @@ -74,7 +93,9 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "androidx-media3" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } +androidx-media3-session = { module = "androidx.media3:media3-session", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } +androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } @@ -117,3 +138,4 @@ protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } room = { id = "androidx.room", version.ref = "room" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "aboutLibraries" } +openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" } diff --git a/scripts/ffmpeg/build_ffmpeg_decoder.sh b/scripts/ffmpeg/build_ffmpeg_decoder.sh index 59718b52..1741c703 100755 --- a/scripts/ffmpeg/build_ffmpeg_decoder.sh +++ b/scripts/ffmpeg/build_ffmpeg_decoder.sh @@ -15,6 +15,7 @@ PROJECT_ROOT="$(realpath "${SCRIPT_DIR}/../../")" ANDROID_ABI=21 ENABLED_DECODERS=(dca ac3 eac3 mlp truehd flac alac pcm_mulaw pcm_alaw mp3) FFMPEG_BRANCH="release/6.0" +DAV1D_BRANCH="1.5.3" # Path configs DIR_PATH="$(pwd)" @@ -22,6 +23,7 @@ TARGET_PATH="$PROJECT_ROOT/app/libs" MEDIA_PATH="$DIR_PATH/ffmpeg_decoder/media" FFMPEG_MODULE_PATH="$MEDIA_PATH/libraries/decoder_ffmpeg/src/main" FFMPEG_PATH="$DIR_PATH/ffmpeg_decoder/ffmpeg" +AV1_MODULE_PATH="$MEDIA_PATH/libraries/decoder_av1/src/main" HOST="$(uname -s | tr '[:upper:]' '[:lower:]')" HOST_PLATFORM="$HOST-x86_64" @@ -48,16 +50,39 @@ else git clone https://github.com/FFmpeg/FFmpeg --depth 1 --single-branch -b "$FFMPEG_BRANCH" ffmpeg fi -ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" +[[ ! -d "${FFMPEG_MODULE_PATH}/jni/ffmpeg" ]] && ln -s "$FFMPEG_PATH" "${FFMPEG_MODULE_PATH}/jni/ffmpeg" pushd "${FFMPEG_MODULE_PATH}/jni" || exit ./build_ffmpeg.sh "${FFMPEG_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" "${ANDROID_ABI}" "${ENABLED_DECODERS[@]}" +# av1 module + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ ! -d cpu_features ]]; then + git clone https://github.com/google/cpu_features --depth 1 --single-branch cpu_features +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +if [[ -d dav1d ]]; then + pushd dav1d || exit + git checkout --force "$DAV1D_BRANCH" +else + git clone https://code.videolan.org/videolan/dav1d --depth 1 --single-branch -b "$DAV1D_BRANCH" dav1d +fi + +pushd "$AV1_MODULE_PATH/jni" || exit + +/usr/bin/env bash ./build_dav1d.sh "${AV1_MODULE_PATH}" "${NDK_PATH}" "${HOST_PLATFORM}" + + pushd "$MEDIA_PATH" || exit -./gradlew :lib-decoder-ffmpeg:assemble +./gradlew :lib-decoder-ffmpeg:assemble :lib-decoder-av1:assemble popd || exit popd || exit cp "$MEDIA_PATH/libraries/decoder_ffmpeg/buildout/outputs/aar/lib-decoder-ffmpeg-release.aar" "$TARGET_PATH/" +cp "$MEDIA_PATH/libraries/decoder_av1/buildout/outputs/aar/lib-decoder-av1-release.aar" "$TARGET_PATH/" popd || exit diff --git a/scripts/mpv/README.md b/scripts/mpv/README.md index 4d41c03a..106907bb 100644 --- a/scripts/mpv/README.md +++ b/scripts/mpv/README.md @@ -6,7 +6,10 @@ This scripts are adapted from https://github.com/mpv-android/mpv-android/tree/ae ```bash cd scripts/mpv -./get_dependencies +./get_dependencies.sh + +# Install build dependencies +pip install meson jsonschema export NDK_PATH=... # Such as ~/Library/Android/sdk/ndk/29.0.14206865 # Build arm64 diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index f94c5c8e..7ca18de8 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -22,17 +22,17 @@ function clone(){ fi } -clone "https://github.com/videolan/dav1d" "1.5.2" dav1d +clone "https://github.com/videolan/dav1d" "1.5.3" dav1d clone "https://github.com/FFmpeg/FFmpeg" "n8.0" ffmpeg clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freetype2 --recurse-submodules -clone "https://github.com/libass/libass" "master" libass +clone "https://github.com/libass/libass" "0.17.4" libass -clone "https://github.com/haasn/libplacebo" "master" libplacebo --recurse-submodules +clone "https://github.com/haasn/libplacebo" "v7.351.0" libplacebo --recurse-submodules -clone "https://github.com/mpv-player/mpv" "master" mpv +clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv if [[ ! -d mbedtls ]]; then mkdir mbedtls diff --git a/settings.gradle.kts b/settings.gradle.kts index c9d815b7..4b95efe8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,9 +9,6 @@ pluginManagement { } mavenCentral() gradlePluginPortal() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } } dependencyResolutionManagement { @@ -19,9 +16,6 @@ dependencyResolutionManagement { repositories { google() mavenCentral() -// maven { -// url = uri("https://androidx.dev/snapshots/builds/14137143/artifacts/repository") -// } } }