diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 75% rename from .github/ISSUE_TEMPLATE/02-new-feature.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 500beea4..5d1b4346 100644 --- a/.github/ISSUE_TEMPLATE/02-new-feature.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,6 +1,4 @@ -name: "Feature Request" -description: Request a new feature -title: "[FEA] - " +title: "<title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index f67a7db7..1aab31da 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,6 +1,6 @@ name: "Bug Report" description: Report a bug -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug" ] @@ -28,7 +28,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -36,7 +36,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 2dc73ff6..34dc317b 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,6 +1,6 @@ name: "Playback Problem" description: Report an issue with playback -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug", "playback" ] @@ -17,6 +17,7 @@ body: attributes: label: "Media info" description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) + placeholder: Use mediainfo or "Send media info to server" in the app to gather this validations: required: true - type: dropdown @@ -35,7 +36,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -43,7 +44,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml deleted file mode 100644 index bd10bd46..00000000 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Question" -description: Ask a question -title: "[QST] - <title>" -labels: [ - "question" -] -body: - - type: textarea - id: description - attributes: - label: "Question" - description: Please enter your question - placeholder: "How do I...?" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..2ee91c39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: New feature / UI suggestion + url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas + about: Please request new features here + - name: Questions + url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a + about: Please ask questions here diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,19 +44,18 @@ jobs: id: buildapp run: | ./gradlew clean assembleDebug testDebugUnitTest --no-daemon - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - name: Tar build dirs + + test-patch: + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Checkout the code + uses: actions/checkout@v6 + with: + fetch-depth: 1 + - name: Test applying patch run: | - tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v6 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v6 - with: - name: APKs - path: "${{ steps.buildapp.outputs.apks }}" - compression-level: 0 + git apply app/src/patches/play_store.patch + git diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..9611c810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,24 @@ jobs: SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" run: | ./gradlew clean assembleRelease --no-daemon + + - name: Build app + id: buildaab + env: + KEY_ALIAS: "${{ secrets.KEY_ALIAS }}" + KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" + KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" + SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + run: | + git apply app/src/patches/play_store.patch + ./gradlew bundleRelease --no-daemon + aab=$(find app/build/outputs -name '*.aab') + echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | echo "Verify APK signatures" - find app/build/outputs/apk -name '*.apk' - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | @@ -59,11 +72,18 @@ jobs: - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum + - name: Upload AAB + uses: actions/upload-artifact@v7 + with: + name: AAB + path: | + app/build/outputs/bundle/**/*.aab + compression-level: 0 - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ "app/build/outputs/apk/**/*.apk" diff --git a/README.md b/README.md index 1b3a0708..6a3c4c65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin </p> -<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> +![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) ## Features @@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ### Customized home page ![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) - ### Movie library browsing -<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> +![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) ### Movie page -<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> +![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) ### Series page -<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> +![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) + +### Genres in library +![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) + ### Playlist -<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> +![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ab4a9cc..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,23 +44,67 @@ android { targetSdk = 36 versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" + } + + signingConfigs { + if (shouldSign) { + create("ci") { + file("ci.keystore").writeBytes( + Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), + ) + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + storePassword = System.getenv("KEY_STORE_PASSWORD") + storeFile = file("ci.keystore") + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } } buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) isDebuggable = false + if (shouldSign) { + signingConfig = signingConfigs.getByName("ci") + } else { + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + val properties = Properties() + properties.load(localPropertiesFile.inputStream()) + val signingConfigName = properties["release.signing.config"]?.toString() + if (signingConfigName != null) { + signingConfig = signingConfigs.getByName(signingConfigName) + } + } + } } + debug { isMinifyEnabled = false isDebuggable = true applicationIdSuffix = ".debug" } + + applicationVariants.all { + val variant = this + variant.outputs + .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } + val outputFileName = + "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" + output.outputFileName = outputFileName + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -81,63 +125,6 @@ android { room { schemaDirectory("$projectDir/schemas") } - signingConfigs { - if (shouldSign) { - create("ci") { - file("ci.keystore").writeBytes( - Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), - ) - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") - storePassword = System.getenv("KEY_STORE_PASSWORD") - storeFile = file("ci.keystore") - enableV1Signing = true - enableV2Signing = true - enableV3Signing = true - enableV4Signing = true - } - } - } - buildTypes { - release { - isMinifyEnabled = false - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } else { - val localPropertiesFile = project.rootProject.file("local.properties") - if (localPropertiesFile.exists()) { - val properties = Properties() - properties.load(localPropertiesFile.inputStream()) - val signingConfigName = properties["release.signing.config"]?.toString() - if (signingConfigName != null) { - signingConfig = signingConfigs.getByName(signingConfigName) - } - } - } - } - debug { - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } - } - - applicationVariants.all { - val variant = this - variant.outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } - val outputFileName = - "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" - output.outputFileName = outputFileName - } - } - } splits { abi { @@ -153,6 +140,12 @@ android { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } protobuf { @@ -267,6 +260,7 @@ dependencies { implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -305,4 +299,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..72cf2896 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,75 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + requestedDestination = Destination.Home(), + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..29b2eb75 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission + android:name="android.permission.READ_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature + android:name="android.software.leanback" + android:required="false" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> + + <!-- Required for Android 11+ to query voice recognition availability --> + <queries> + <intent> + <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> + </intent> + </queries> + + <application + android:allowBackup="true" + android:banner="@mipmap/ic_banner" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Wholphin" + android:name=".WholphinApplication" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <activity android:name=".test.TestActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.provider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + tools:node="remove" /> + +<!-- <service--> +<!-- android:name=".WholphinDreamService"--> +<!-- android:exported="true"--> +<!-- android:label="@string/app_name"--> +<!-- android:permission="android.permission.BIND_DREAM_SERVICE">--> +<!-- <intent-filter>--> +<!-- <action android:name="android.service.dreams.DreamService" />--> +<!-- <category android:name="android.intent.category.DEFAULT" />--> +<!-- </intent-filter>--> +<!-- </service>--> + </application> + +</manifest> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 161352af..4479ae86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,17 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + <service + android:name=".WholphinDreamService" + android:exported="true" + android:label="@string/app_name" + android:permission="android.permission.BIND_DREAM_SERVICE"> + <intent-filter> + <action android:name="android.service.dreams.DreamService" /> + <category android:name="android.intent.category.DEFAULT" /> + </intent-filter> + </service> </application> </manifest> 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 34bd871c..9cb740ab 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels @@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -19,28 +18,16 @@ 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.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 -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference 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 @@ -49,6 +36,7 @@ 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.ScreensaverService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager @@ -61,11 +49,9 @@ import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.launchDefault 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 import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree @@ -74,8 +60,13 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach 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 @@ -102,9 +93,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var updateChecker: UpdateChecker - @Inject - lateinit var appUpgradeHandler: AppUpgradeHandler - @Inject lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver @@ -123,6 +111,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -131,6 +122,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + @Inject + lateinit var screensaverService: ScreensaverService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -139,11 +133,7 @@ class MainActivity : AppCompatActivity() { instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) - if (savedInstanceState == null) { - lifecycleScope.launchIO { - appUpgradeHandler.copySubfont(false) - } - } + viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -154,6 +144,20 @@ class MainActivity : AppCompatActivity() { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } + screensaverService.keepScreenOn + .onEach { keepScreenOn -> + Timber.v("keepScreenOn: %s", keepScreenOn) + withContext(Dispatchers.Main) { + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + }.catch { ex -> + Timber.e(ex, "Error with keepScreenOn") + }.launchIn(lifecycleScope) + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) @@ -201,115 +205,19 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - 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), - ) - } - } - } - } - } - } - }, + ProvideLocalClock { + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() + } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), ) } } @@ -318,11 +226,22 @@ class MainActivity : AppCompatActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (screensaverService.state.value.show) { + screensaverService.stop(false) + screensaverService.pulse() + return true + } else { + screensaverService.pulse() + return super.dispatchKeyEvent(event) + } + } + override fun onResume() { super.onResume() Timber.d("onResume") - lifecycleScope.launchIO { - appUpgradeHandler.run() + lifecycleScope.launchDefault { + screensaverService.pulse() } } @@ -335,6 +254,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } @@ -346,6 +266,22 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { @@ -440,10 +376,13 @@ class MainActivityViewModel private val navigationManager: SetupNavigationManager, private val deviceProfileService: DeviceProfileService, private val backdropService: BackdropService, + private val appUpgradeHandler: AppUpgradeHandler, ) : ViewModel() { fun appStart() { viewModelScope.launchIO { try { + appUpgradeHandler.run() + appUpgradeHandler.copySubfont(false) val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val userHasPin = serverRepository.currentUser.value?.hasPin == true diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..5bade867 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +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.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.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +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.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.components.AppScreensaver +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.util.ProvideLocalClock + +@Composable +fun MainContent( + backStack: MutableList<SetupDestination>, + navigationManager: NavigationManager, + appPreferences: AppPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + requestedDestination: Destination, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + 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), + ) + } + } + } + } + } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } + } +} 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 cf2c159a..ceb7264e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi @@ -27,16 +26,6 @@ class WholphinApplication : init { instance = this - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .build(), - ) - } - if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { @@ -64,6 +53,23 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectNetwork() + .penaltyLog() + .penaltyDeathOnNetwork() + .build(), + ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) + } OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt new file mode 100644 index 00000000..a4c09ad4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -0,0 +1,119 @@ +package com.github.damontecres.wholphin + +import android.service.dreams.DreamService +import androidx.compose.foundation.layout.fillMaxSize +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.datastore.core.DataStore +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +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.ScreensaverService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@AndroidEntryPoint +class WholphinDreamService : + DreamService(), + SavedStateRegistryOwner { + @Inject + lateinit var serverRepository: ServerRepository + + @Inject + lateinit var screensaverService: ScreensaverService + + @Inject + lateinit var preferencesDataStore: DataStore<AppPreferences> + + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + lifecycleScope.launchDefault { + if (serverRepository.current.value == null) { + val prefs = preferencesDataStore.data.first() + serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull()) + } + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val itemFlow = screensaverService.createItemFlow(lifecycleScope) + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@WholphinDreamService) + setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) + setContent { + val user by serverRepository.currentUser.observeAsState() + if (user != null) { + var prefs by remember { mutableStateOf<AppPreferences?>(null) } + LaunchedEffect(Unit) { + preferencesDataStore.data.collectLatest { prefs = it } + } + prefs?.let { prefs -> + WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) { + ProvideLocalClock { + val screensaverPrefs = prefs.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } +} 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 index 59f5d9ea..3a2c325c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -47,7 +47,7 @@ interface SeerrServerDao { suspend fun deleteUser( serverId: Int, jellyfinUserRowId: Int, - ) + ): Int suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) 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 f013525b..2d786ed8 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 @@ -9,10 +9,12 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -41,9 +43,8 @@ class ServerRepository val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { - private val sharedPreferences = getServerSharedPreferences(context) - private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -59,7 +60,7 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) @@ -73,7 +74,7 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ): CurrentUser? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -107,7 +108,7 @@ class ServerRepository _current.value = CurrentUser(updatedServer, updatedUser) _currentUserDto.value = userDto } - sharedPreferences.edit(true) { + getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } @@ -128,7 +129,7 @@ class ServerRepository return null } val serverAndUsers = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.getServer(serverId) } if (serverAndUsers != null) { @@ -151,7 +152,7 @@ class ServerRepository } suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverId?.let { serverDao.getServer(serverId)?.server } } @@ -165,7 +166,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -215,7 +216,7 @@ class ServerRepository } apiClient.update(accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } @@ -235,7 +236,7 @@ class ServerRepository } apiClient.update(baseUrl = null, accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } @@ -254,7 +255,7 @@ class ServerRepository suspend fun setUserPin( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val newUser = user.copy(pin = pin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { @@ -267,7 +268,7 @@ class ServerRepository } suspend fun authorizeQuickConnect(code: String): Boolean = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val userId = currentUser.value?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") 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 172c6ef5..3c34851b 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 @@ -25,6 +25,7 @@ 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 java.util.UUID import kotlin.time.Duration @Serializable @@ -33,6 +34,7 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, val imageUrlOverride: String? = null, + val destinationOverride: Destination? = null, ) : CardGridItem { val id get() = data.id @@ -70,6 +72,8 @@ data class BaseItem( } } + val canDelete: Boolean get() = data.canDelete == true + @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } @@ -93,7 +97,11 @@ data class BaseItem( data.indexNumber?.let { "E$it" } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = - if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) { + if (type == BaseItemKind.SERIES || + type == BaseItemKind.SEASON || + type == BaseItemKind.EPISODE || + type == BaseItemKind.BOX_SET + ) { data.indexNumber?.let { "E$it" } ?: data.userData ?.unplayedItemCount @@ -166,6 +174,7 @@ data class BaseItem( }?.toIntOrNull() fun destination(index: Int? = null): Destination { + if (destinationOverride != null) return destinationOverride val result = // Redirect episodes & seasons to their series if possible when (type) { @@ -234,3 +243,28 @@ data class BaseItemUi( val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) + +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List<BaseItemKind>?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 1bcacb3f..3444ac54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -31,6 +31,7 @@ data class Chapter( ) }, ) - }.orEmpty() + }?.sortedBy { it.position } + .orEmpty() } } 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 index a274caa1..0cf88ff1 100644 --- 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 @@ -2,25 +2,17 @@ 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 androidx.compose.runtime.Stable 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 @@ -74,6 +66,7 @@ enum class SeerrAvailability( /** * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. */ +@Stable @Serializable data class DiscoverItem( val id: Int, @@ -83,17 +76,14 @@ data class DiscoverItem( val overview: String?, val availability: SeerrAvailability, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, - val posterPath: String?, - val backdropPath: String?, + val posterUrl: String?, + val backDropUrl: 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 = @@ -112,103 +102,6 @@ data class DiscoverItem( 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( 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 c12d37b5..ac4a4710 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,6 +1,10 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -19,19 +23,21 @@ data class Person( ) { companion object { fun fromDto( + context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = false, ) fun fromDto( + context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -39,10 +45,51 @@ data class Person( Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = favorite, ) } } + +private fun personRole( + context: Context, + role: String?, + type: PersonKind, +): String? = + if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { + role + } else if (role.equals(type.name, ignoreCase = true)) { + type.stringRes?.let { context.getString(it) } + } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { + role + } else { + listOfNotNull( + role?.takeIf { it.isNotNullOrBlank() }, + type.stringRes?.let { context.getString(it) }, + ).takeIf { it.isNotEmpty() }?.joinToString(" - ") + } + +@get:StringRes +private val PersonKind.stringRes: Int? + get() = + when (this) { + PersonKind.UNKNOWN -> R.string.unknown + PersonKind.ACTOR -> R.string.actor + PersonKind.DIRECTOR -> R.string.director + PersonKind.COMPOSER -> R.string.composer + PersonKind.WRITER -> R.string.writer + PersonKind.GUEST_STAR -> R.string.guest_star + PersonKind.PRODUCER -> R.string.producer + PersonKind.CONDUCTOR -> R.string.conductor + PersonKind.LYRICIST -> R.string.lyricist + PersonKind.ARRANGER -> R.string.arranger + PersonKind.ENGINEER -> R.string.engineer + PersonKind.MIXER -> R.string.mixer + PersonKind.REMIXER -> R.string.mixer + PersonKind.CREATOR -> R.string.creator + PersonKind.ARTIST -> R.string.artist + PersonKind.ALBUM_ARTIST -> R.string.artist + else -> null + } 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 84e91c95..12a1d529 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 @@ -775,6 +775,18 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference<AppPreferences>( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference<AppPreferences>( title = R.string.one_click_pause, @@ -1031,6 +1043,12 @@ sealed interface AppPreference<Pref, T> { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val ScreensaverSettings = + AppDestinationPreference<AppPreferences>( + title = R.string.screensaver_settings, + destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), + ) } } @@ -1045,6 +1063,7 @@ val basicPreferences = AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -1138,6 +1157,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ManageMedia, AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, @@ -1236,6 +1256,24 @@ val liveTvPreferences = AppPreference.LiveTvColorCodePrograms, ) +val screensaverPreferences = + listOf( + PreferenceGroup( + title = R.string.screensaver, + preferences = + listOf( + ScreensaverPreference.Enabled, + ScreensaverPreference.StartDelay, + ScreensaverPreference.Duration, + ScreensaverPreference.ShowClock, + ScreensaverPreference.Animate, + ScreensaverPreference.MaxAge, + ScreensaverPreference.ItemTypes, + ScreensaverPreference.Start, + ), + ), + ) + data class AppSwitchPreference<Pref>( @get:StringRes override val title: Int, override val defaultValue: Boolean, @@ -1295,8 +1333,6 @@ data class AppMultiChoicePreference<Pref, T>( override val getter: (prefs: Pref) -> List<T>, override val setter: (prefs: Pref, value: List<T>) -> Pref, @param:StringRes val summary: Int? = null, - val toSharedPrefs: (T) -> String, - val fromSharedPrefs: (String) -> T?, ) : AppPreference<Pref, List<T>> data class AppClickablePreference<Pref>( 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 6adcdca4..bf8ab2a4 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 @@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException +import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -120,6 +121,20 @@ class AppPreferencesSerializer colorCodePrograms = AppPreference.LiveTvColorCodePrograms.defaultValue }.build() + + screensaverPreference = + ScreensaverPreferences + .newBuilder() + .apply { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + showClock = ScreensaverPreference.ShowClock.defaultValue + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + }.build() }.build() advancedPreferences = @@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder photoPreferences = photoPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + screensaverPreference = screensaverPreference.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt new file mode 100644 index 00000000..1e1ad91c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.time.Duration.Companion.milliseconds + +object ScreensaverPreference { + val Enabled = + AppSwitchPreference<AppPreferences>( + title = R.string.in_app_screensaver, + defaultValue = false, + getter = { it.interfacePreferences.screensaverPreference.enabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + const val DEFAULT_START_DELAY = 15 * 60_000L + private val startDelayValues = + listOf( + 30_000L, + 60_000L, + 2 * 60_000L, + 5 * 60_000L, + 10 * 60_000L, + 15 * 60_000L, + 30 * 60_000L, + 60 * 60_000L, + ) + val StartDelay = + AppSliderPreference<AppPreferences>( + title = R.string.start_after, + defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), + min = 0, + max = startDelayValues.size - 1L, + interval = 1, + getter = { + startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + startDelay = startDelayValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY + v.milliseconds.toString() + } else { + null + } + }, + ) + + const val DEFAULT_DURATION = 30_000L + private val durationValues = + listOf( + 15_000L, + 30_000L, + 60_000L, + 2 * 60_000L, + ) + val Duration = + AppSliderPreference<AppPreferences>( + title = R.string.duration, + defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), + min = 0, + max = durationValues.size - 1L, + interval = 1, + getter = { + durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + duration = durationValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION + v.milliseconds.toString() + } else { + null + } + }, + ) + + val ShowClock = + AppSwitchPreference<AppPreferences>( + title = R.string.show_clock, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.showClock }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { showClock = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val Animate = + AppSwitchPreference<AppPreferences>( + title = R.string.animate, + defaultValue = true, + getter = { it.interfacePreferences.screensaverPreference.animate }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { animate = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + const val DEFAULT_MAX_AGE = 16 + private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) + val MaxAge = + AppSliderPreference<AppPreferences>( + title = R.string.max_age_rating, + defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), + min = 0, + max = maxAgeValues.size - 1L, + interval = 1, + getter = { + it.interfacePreferences.screensaverPreference.maxAgeFilter + .takeIf { it >= 0 } + ?.let { maxAgeValues.indexOf(it).toLong() } + ?: maxAgeValues.lastIndex.toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + maxAgeFilter = maxAgeValues[value.toInt()] + } + }, + summarizer = { value -> + when (value) { + null -> { + null + } + + maxAgeValues.lastIndex.toLong() -> { + WholphinApplication.instance.getString(R.string.no_max) + } + + 0L -> { + WholphinApplication.instance.getString(R.string.for_all_ages) + } + + else -> { + WholphinApplication.instance.getString( + R.string.up_to_age, + maxAgeValues[value.toInt()].toString(), + ) + } + } + }, + ) + + val ItemTypes = + AppMultiChoicePreference<AppPreferences, BaseItemKind>( + title = R.string.include_types, + summary = R.string.include_types_summary, + defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), + displayValues = R.array.screensaver_item_types, + getter = { + it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> + BaseItemKind.fromName(type) + } + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + clearItemTypes() + addAllItemTypes(value.map { it.serialName }) + } + }, + ) + + val Start = + AppClickablePreference<AppPreferences>( + title = R.string.start_screensaver, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 057ec550..ca720af3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -6,8 +6,10 @@ import androidx.core.content.edit import androidx.datastore.core.DataStore import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences @@ -16,11 +18,14 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings +import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.io.File import javax.inject.Inject @@ -32,9 +37,14 @@ class AppUpgradeHandler constructor( @param:ApplicationContext private val context: Context, private val appPreferences: DataStore<AppPreferences>, + private val seerrServerDao: SeerrServerDao, ) { suspend fun run() { - val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0) + val pkgInfo = + WholphinApplication.instance.packageManager.getPackageInfo( + WholphinApplication.instance.packageName, + 0, + ) val prefs = PreferenceManager.getDefaultSharedPreferences(context) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) @@ -59,14 +69,16 @@ class AppUpgradeHandler try { copySubfont(true) upgradeApp( - context, - Version.Companion.fromString(previousVersion ?: "0.0.0"), - Version.Companion.fromString(newVersion), + Version.fromString(previousVersion ?: "0.0.0"), + Version.fromString(newVersion), appPreferences, ) } catch (ex: Exception) { Timber.e(ex, "Exception during app upgrade") } + Timber.i("App upgrade complete") + } else { + Timber.d("No app update needed") } } @@ -97,110 +109,108 @@ class AppUpgradeHandler const val VERSION_NAME_CURRENT_KEY = "version.current.name" const val VERSION_CODE_CURRENT_KEY = "version.current.code" } - } -suspend fun upgradeApp( - context: Context, - previous: Version, - current: Version, - appPreferences: DataStore<AppPreferences>, -) { - if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { - appPreferences.updateData { - it.updatePlaybackOverrides { - ac3Supported = AppPreference.Ac3Supported.defaultValue - downmixStereo = AppPreference.DownMixStereo.defaultValue - directPlayAss = AppPreference.DirectPlayAss.defaultValue - directPlayPgs = AppPreference.DirectPlayPgs.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - showClock = AppPreference.ShowClock.defaultValue - } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { - PreferencesViewModel.resetSubtitleSettings(appPreferences) - } - if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - margin = SubtitleSettings.Margin.defaultValue.toInt() - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { - appPreferences.updateData { - it.updateAdvancedPreferences { - if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { - imageDiskCacheSizeBytes = - AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + suspend fun upgradeApp( + previous: Version, + current: Version, + appPreferences: DataStore<AppPreferences>, + ) { + if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { + appPreferences.updateData { + it.updatePlaybackOverrides { + ac3Supported = AppPreference.Ac3Supported.defaultValue + downmixStereo = AppPreference.DownMixStereo.defaultValue + directPlayAss = AppPreference.DirectPlayAss.defaultValue + directPlayPgs = AppPreference.DirectPlayPgs.defaultValue + } } } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = AppPreference.MpvGpuNext.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { - appPreferences.updateData { - it.update { - signInAutomatically = AppPreference.SignInAuto.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { - appPreferences.updateData { - it.updateSubtitlePreferences { - if (edgeThickness < 1) { - edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue + } } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { - appPreferences.updateData { - it.updateLiveTvPreferences { - showHeader = AppPreference.LiveTvShowHeader.defaultValue - favoriteChannelsAtBeginning = - AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue - sortByRecentlyWatched = - AppPreference.LiveTvChannelSortByWatched.defaultValue - colorCodePrograms = - AppPreference.LiveTvColorCodePrograms.defaultValue - } - } - } - - if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { - if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + showClock = AppPreference.ShowClock.defaultValue + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) { + PreferencesViewModel.resetSubtitleSettings(appPreferences) + } + if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + margin = SubtitleSettings.Margin.defaultValue.toInt() + } } } - } - } - // TODO temporarily disabled until some MPV bugs are fixed + if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) { + appPreferences.updateData { + it.updateAdvancedPreferences { + if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) { + imageDiskCacheSizeBytes = + AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT + } + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = AppPreference.MpvGpuNext.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) { + appPreferences.updateData { + it.update { + signInAutomatically = AppPreference.SignInAuto.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) { + appPreferences.updateData { + it.updateSubtitlePreferences { + if (edgeThickness < 1) { + edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + } + } + } + } + if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) { + appPreferences.updateData { + it.updateLiveTvPreferences { + showHeader = AppPreference.LiveTvShowHeader.defaultValue + favoriteChannelsAtBeginning = + AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue + sortByRecentlyWatched = + AppPreference.LiveTvChannelSortByWatched.defaultValue + colorCodePrograms = + AppPreference.LiveTvColorCodePrograms.defaultValue + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) { + if (Build.MODEL.equals("shield android tv", ignoreCase = true)) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } + } + } + + // TODO temporarily disabled until some MPV bugs are fixed // if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) { // appPreferences.updateData { // it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV } @@ -208,42 +218,67 @@ suspend fun upgradeApp( // showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG) // } - if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { - appPreferences.updateData { - it.updateMpvOptions { - useGpuNext = false + if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) { + appPreferences.updateData { + it.updateMpvOptions { + useGpuNext = false + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { - appPreferences.updateData { - it.updateInterfacePreferences { - subtitlesPreferences = - subtitlesPreferences - .toBuilder() - .apply { - imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() - }.build() - // Copy current subtitle prefs as HDR ones - hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = + SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { - appPreferences.updateData { - it.updatePhotoPreferences { - slideshowDuration = AppPreference.SlideshowDuration.defaultValue + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } } - } - } - if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { - appPreferences.updateData { - it.updateHomePagePreferences { - maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } + + if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) { + seerrServerDao.getServers().forEach { + val server = it.server + seerrServerDao.updateServer( + server.copy(url = migrateSeerrUrl(server.url)), + ) + } } } } -} 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 e082c5fa..e0c48cca 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 @@ -52,7 +52,7 @@ class BackdropService if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } submit(item.id.toString(), imageUrl) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 93dc32d2..b8a850ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions +import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -567,6 +569,7 @@ class HomeSettingsService userDto: UserDto, libraries: List<Library>, limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, ): HomeRowLoadingState = when (row) { is HomeRowConfig.ContinueWatching -> { @@ -647,25 +650,42 @@ class HomeSettingsService val genreImages = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = scope, imageUrlService = imageUrlService, genres = genreIds, parentId = row.parentId, includeItemTypes = null, cardWidthPx = null, + useCache = isRefresh, ) - val genres = - items.map { - BaseItem(it, false, genreImages[it.id]) - } - - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name + val title = - name?.let { context.getString(R.string.genres_in, it) } + library?.name?.let { context.getString(R.string.genres_in, it) } ?: context.getString(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } Success( title, 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 f0de9d24..1dd642b0 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,18 +28,16 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, - imageTags: Map<ImageType, String?>, imageType: ImageType, + imageTags: Map<ImageType, String?>, + backdropTags: List<String>, parentThumbId: UUID? = null, parentBackdropId: UUID? = null, - backdropTags: List<String> = emptyList(), fillWidth: Int? = null, fillHeight: Int? = null, ): String? = when (imageType) { - ImageType.BACKDROP, - ImageType.LOGO, - -> { + ImageType.LOGO -> { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { getItemImageUrl( itemId = seriesId, @@ -57,6 +55,27 @@ class ImageUrlService } } + ImageType.BACKDROP, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (backdropTags.isNotEmpty()) { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } + ImageType.THUMB -> { if (useSeriesForPrimary && parentThumbId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt new file mode 100644 index 00000000..a13881a9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,110 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow<DeletedItem>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + val appPreferences = userPreferencesService.getCurrent().appPreferences + return canDelete(item, appPreferences) + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + // TODO enable + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index adb21ff7..b83f0bd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -11,15 +11,19 @@ import com.github.damontecres.wholphin.ui.main.settings.Library 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.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -60,7 +64,11 @@ class NavDrawerService if (user != null && userDto != null && user.id == userDto.id) { updateNavDrawer(user, userDto) } + }.catch { ex -> + Timber.e(ex, "Error updating nav drawer") + showToast(context, "Error fetching user's views") }.launchIn(coroutineScope) + seerrServerRepository.active .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } @@ -143,7 +151,9 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + withContext(Dispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } val items = mutableListOf<NavDrawerItem>() val moreItems = mutableListOf<NavDrawerItem>() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index b4967b03..6a35dda5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.services +import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty +import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject @@ -12,6 +14,7 @@ import javax.inject.Singleton class PeopleFavorites @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -27,7 +30,14 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } + people.map { + Person.fromDto( + context, + it, + favorites[it.id] ?: false, + api, + ) + } }.orEmpty() people } 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 2ca3d1b1..06d116a4 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 @@ -158,33 +158,58 @@ class RefreshRateService 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 - } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } } 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 } + candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } else { + // Exact resolution & frame rate candidates.firstOrNull { it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate } - ?: candidates.firstOrNull { - it.physicalWidth == streamWidth && + // Next highest resolution, exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate } + // Exact resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Next highest resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && + it.physicalHeight >= streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Fall back to largest resolution, exact frame rate ?: candidates .filter { it.refreshRateRounded == streamRate } .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Finally, just the highest resolution + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } } + + fun frameRateMatches( + refreshRateRounded: Int, + streamRate: Int, + ): Boolean { + return refreshRateRounded % streamRate == 0 || // Exact multiple + refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt new file mode 100644 index 00000000..c81893b3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import coil3.imageLoader +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.components.CurrentItem +import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class ScreensaverService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val scope: CoroutineScope, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) + val state: StateFlow<ScreensaverState> = _state + + val keepScreenOn = MutableStateFlow(false) + + private var waitJob: Job? = null + + init { + userPreferencesService.flow + .onEach { prefs -> + _state.update { + val enabled = + prefs.appPreferences.interfacePreferences.screensaverPreference.enabled + keepScreenOnInternal(enabled) + ScreensaverState(enabled, false, false, false) + } + }.launchIn(scope) + } + + fun pulse() { + waitJob?.cancel() + if (_state.value.enabled) { +// Timber.v("pulse") + _state.update { + if (!it.active) { + it.copy(active = false) + } else { + it + } + } + + if (!_state.value.paused) { + waitJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + _state.update { + it.copy(active = true) + } + } + } + } + } + + fun start() { + _state.update { + it.copy( + enabledTemp = true, + active = true, + ) + } + } + + fun stop(cancelJob: Boolean) { + _state.update { + it.copy( + enabledTemp = false, + active = false, + ) + } + if (cancelJob) waitJob?.cancel() + } + + fun keepScreenOn(keep: Boolean) { + scope.launchDefault { + val screensaverEnabled = _state.value.enabled + Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) + if (screensaverEnabled) { + // Page is requesting to keep screen on, so we don't wait to show the screensaver + _state.update { + it.copy(active = false, paused = keep) + } + if (!keep) { + pulse() + } + } else { + keepScreenOnInternal(keep) + } + } + } + + private fun keepScreenOnInternal(keep: Boolean) { + keepScreenOn.update { keep } + } + + fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = + flow { + val prefs = + userPreferencesService.flow + .first() + .appPreferences + .interfacePreferences.screensaverPreference + val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } + val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } + val request = + GetItemsRequest( + recursive = true, + includeItemTypes = itemTypes, + imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), + sortBy = listOf(ItemSortBy.RANDOM), + maxOfficialRating = maxAge?.toString(), + hasParentalRating = maxAge?.let { true }, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() + Timber.v("Got %s items", pager.size) + var index = 0 + if (pager.isEmpty()) { + emit(null) + } else { + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds + while (true) { + try { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { + context.imageLoader + .enqueue( + ImageRequest + .Builder(context) + .data(backdropUrl) + .build(), + ).job + .await() + emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + delay(duration) + } + } + } catch (_: CancellationException) { + break + } catch (ex: Exception) { + Timber.e(ex, "Error fetching next item") + delay(duration) + } + index++ + if (index > pager.lastIndex) index = 0 + } + } + }.flowOn(Dispatchers.Default).cancellable() + } + +data class ScreensaverState( + val enabled: Boolean, + val enabledTemp: Boolean, + val active: Boolean, + val paused: Boolean, +) { + val show get() = (enabled || enabledTemp) && active && !paused +} 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 index c68cd3d2..5c62655f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrApi.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import okhttp3.OkHttpClient /** @@ -24,6 +25,6 @@ class SeerrApi( baseUrl: String, apiKey: String?, ) { - api = SeerrApiClient(baseUrl, apiKey, okHttpClient) + api = SeerrApiClient(createSeerrApiUrl(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 index 78ff7d3b..0f073af6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -11,6 +11,7 @@ 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.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer @@ -18,15 +19,19 @@ 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.ui.setup.seerr.createSeerrApiUrl 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.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient +import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -54,6 +59,7 @@ class SeerrServerRepository connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } val currentUser: Flow<SeerrUser?> = connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } + val currentUserId: Flow<Int?> = current.map { it?.config?.id } /** * Whether Seerr integration is currently active of not @@ -67,10 +73,11 @@ class SeerrServerRepository } fun error( - serverUrl: String, + server: SeerrServer, + user: SeerrUser, exception: Exception, ) { - _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } + _connection.update { SeerrConnectionStatus.Error(server, user, exception) } seerrApi.update("", null) } @@ -158,11 +165,11 @@ class SeerrServerRepository val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } val api = SeerrApiClient( - url, + createSeerrApiUrl(url), apiKey, okHttpClient .newBuilder() - .connectTimeout(5.seconds) + .connectTimeout(2.seconds) .readTimeout(6.seconds) .build(), ) @@ -170,10 +177,16 @@ class SeerrServerRepository return LoadingState.Success } - suspend fun removeServer() { - val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return - seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + suspend fun removeServerForCurrentUser(): Boolean { + val user = + when (val conn = connection.first()) { + SeerrConnectionStatus.NotConfigured -> return false + is SeerrConnectionStatus.Error -> conn.user + is SeerrConnectionStatus.Success -> conn.current.user + } + val rows = seerrServerDao.deleteUser(user) clear() + return rows > 0 } } @@ -186,7 +199,8 @@ sealed interface SeerrConnectionStatus { data object NotConfigured : SeerrConnectionStatus data class Error( - val serverUrl: String, + val server: SeerrServer, + val user: SeerrUser, val ex: Exception, ) : SeerrConnectionStatus @@ -266,47 +280,77 @@ class UserSwitchListener seerrServerRepository.clear() homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - // Check for home settings - launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set(server, seerrUser, userConfig) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server.url, ex) - } - } - } - } + switchUser(user) } } } } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server, seerrUser, ex) + } + } + } + } + } } + +fun CurrentSeerr?.imageUrlBuilder( + imageType: ImageType, + path: String?, +): String? { + if (this == null) return null + val cacheImages = serverConfig.cacheImages == true + val base = + if (cacheImages) { + server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" +} 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 index e38ba41b..0c1801ac 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -1,11 +1,25 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +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.MediaInfo +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieResult import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvResult import com.github.damontecres.wholphin.data.model.BaseItem 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.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.toLocalDate import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.serializer.toUUIDOrNull import javax.inject.Inject import javax.inject.Singleton @@ -22,6 +36,7 @@ class SeerrService constructor( private val seerApi: SeerrApi, private val seerrServerRepository: SeerrServerRepository, + private val imageUrlService: ImageUrlService, ) { val api: SeerrApiClient get() = seerApi.api @@ -40,35 +55,35 @@ class SeerrService api.searchApi .discoverTvGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun trending(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTrendingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverMoviesUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> = api.searchApi .discoverTvUpcomingGet(page = page) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() /** @@ -89,14 +104,14 @@ class SeerrService api.moviesApi .movieMovieIdSimilarGet(movieId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { api.tvApi .tvTvIdSimilarGet(tvId = it) .results - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } } BaseItemKind.PERSON -> { @@ -106,12 +121,12 @@ class SeerrService val cast = credits.cast ?.take(25) - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() val crew = credits.crew ?.take(25) - ?.map(::DiscoverItem) + ?.map { createDiscoverItem(it) } .orEmpty() cast + crew } @@ -125,4 +140,186 @@ class SeerrService } else { null } + + suspend fun getTvSeries(item: BaseItem): TvDetails? = + if (active.first()) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { tvId -> + api.tvApi.tvTvIdGet(tvId = tvId) + } + } else { + null + } + + private suspend fun createImageUrl( + imageType: ImageType, + path: String?, + mediaInfo: MediaInfo?, + ): String? { + if (mediaInfo != null) { + val itemId = + if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) { + mediaInfo.jellyfinMediaId.toUUIDOrNull() + } else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) { + mediaInfo.jellyfinMediaId4k.toUUIDOrNull() + } else { + null + } + if (itemId != null) { + return imageUrlService.getItemImageUrl( + itemId = itemId, + imageType = imageType, + ) + } + } + val current = seerrServerRepository.current.firstOrNull() ?: return null + val cacheImages = current.serverConfig.cacheImages == true + val base = + if (cacheImages) { + current.server.url.removeSuffix("/") + "/imageproxy/tmdb" + } else { + "https://image.tmdb.org" + } + val prefix = + when (imageType) { + ImageType.PRIMARY -> "/t/p/w500" + ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces" + else -> throw IllegalArgumentException("Image type not supported: $imageType") + } + return "${base}${prefix}$path" + } + + suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem = + DiscoverItem( + 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), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem = + DiscoverItem( + 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), + posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo), + jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(tv: TvResult): DiscoverItem = + DiscoverItem( + 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), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem = + DiscoverItem( + 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), + posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo), + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem = + DiscoverItem( + 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), + posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo), + backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo), + jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem = + DiscoverItem( + 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), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem = + DiscoverItem( + 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), + posterUrl = + createImageUrl( + ImageType.PRIMARY, + credit.posterPath ?: credit.profilePath, + credit.mediaInfo, + ), + backDropUrl = + createImageUrl( + ImageType.BACKDROP, + credit.backdropPath, + credit.mediaInfo, + ), + jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index bd95ea1d..e4910031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class SetupNavigationManager @Inject constructor() { - var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) + var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt index edca582b..86bab2f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -8,6 +8,7 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository @@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.util.UUID import javax.inject.Inject +import kotlin.random.Random import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -31,7 +34,6 @@ class SuggestionsSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, - private val cache: SuggestionsCache, private val workManager: WorkManager, ) { private val activity = @@ -42,6 +44,7 @@ class SuggestionsSchedulerService // Exposed for testing internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } init { serverRepository.current.observe(activity) { user -> @@ -60,6 +63,28 @@ class SuggestionsSchedulerService userId: UUID, serverId: UUID, ) { + val workInfos = + withContext(dispatcher) { + workManager + .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) + .get() + } + val activeWork = + workInfos.firstOrNull { + !it.state.isFinished + } + val scheduledUserId = + activeWork + ?.tags + ?.firstOrNull { it.startsWith("user:") } + ?.removePrefix("user:") + + val isAlreadyScheduledForUser = scheduledUserId == userId.toString() + if (isAlreadyScheduledForUser) { + Timber.d("SuggestionsWorker already scheduled for user %s", userId) + return + } + val constraints = Constraints .Builder() @@ -75,12 +100,18 @@ class SuggestionsSchedulerService val periodicWorkRequestBuilder = PeriodicWorkRequestBuilder<SuggestionsWorker>( repeatInterval = 12.hours.toJavaDuration(), + flexTimeInterval = 1.hours.toJavaDuration(), ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - .setInitialDelay(60.seconds.toJavaDuration()) + .addTag("user:$userId") + + val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) + periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) + + Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt index eab87f20..4560f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -99,11 +99,17 @@ class SuggestionsWorker itemsPerRow, ) ensureActive() + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, view.id, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) + return@runCatching + } cache.put( userId, view.id, itemKind, - suggestions.map { it.id }, + newIds, ) }.onFailure { e -> Timber.e( @@ -242,7 +248,7 @@ class SuggestionsWorker GetItemsRequest( parentId = parentId, userId = userId, - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, + fields = extraFields, includeItemTypes = listOf(itemKind), genreIds = genreIds, recursive = true, @@ -252,7 +258,7 @@ class SuggestionsWorker sortOrder = sortOrder?.let { listOf(it) }, limit = limit, enableTotalRecordCount = false, - imageTypeLimit = 1, + imageTypeLimit = 0, ) return GetItemsRequestHandler .execute(api, request) 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 f4aa80dc..709549cf 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 @@ -14,7 +14,9 @@ import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager +import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.UpdateChecker.Companion.ASSET_NAME import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.showToast @@ -51,9 +53,8 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - // TODO apk names - private const val ASSET_NAME = "Wholphin" - private const val APK_NAME = "$ASSET_NAME.apk" + const val ASSET_NAME = "Wholphin" + const val APK_NAME = "$ASSET_NAME.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" @@ -118,11 +119,6 @@ class UpdateChecker suspend fun getLatestRelease(updateUrl: String): Release? { return withContext(Dispatchers.IO) { - val preferRelease = - PreferenceManager - .getDefaultSharedPreferences(context) - .getBoolean("updatePreferRelease", true) - val request = Request .Builder() @@ -140,7 +136,7 @@ class UpdateChecker val downloadUrl = result.jsonObject["assets"] ?.jsonArray - ?.let { assets -> getDownloadUrl(assets, preferRelease) } + ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } Timber.v("version=$version, downloadUrl=$downloadUrl") if (version != null) { val notes = @@ -165,35 +161,6 @@ class UpdateChecker } } - private fun getDownloadUrl( - assets: JsonArray, - preferRelease: Boolean, - ): String? { - val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" } - val releaseSuffix = if (preferRelease) "-release" else "-debug" - val preferredNames = - listOf( - "$ASSET_NAME${releaseSuffix}$abiSuffix.apk", - "$ASSET_NAME$releaseSuffix.apk", - "$ASSET_NAME.apk", - ) - var preferredAsset: JsonObject? = null - outer@ for (name in preferredNames) { - for (asset in assets) { - val assetName = - asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull - if (name == assetName) { - preferredAsset = asset.jsonObject - break@outer - } - } - } - return preferredAsset - ?.get("browser_download_url") - ?.jsonPrimitive - ?.contentOrNull - } - suspend fun installRelease( release: Release, callback: DownloadCallback, @@ -387,3 +354,33 @@ suspend fun copyTo( } return bytesCopied } + +fun getDownloadUrl( + assets: JsonArray, + debug: Boolean, + supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(), +): String? { + val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" } + val releaseSuffix = if (debug) "-debug" else "-release" + val preferredNames = + buildList { + add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk") + add("$ASSET_NAME$releaseSuffix.apk") + if (!debug) add("$ASSET_NAME.apk") + } + var preferredAsset: JsonObject? = null + outer@ for (name in preferredNames) { + for (asset in assets) { + val assetName = + asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull + if (name == assetName) { + preferredAsset = asset.jsonObject + break@outer + } + } + } + return preferredAsset + ?.get("browser_download_url") + ?.jsonPrimitive + ?.contentOrNull +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 90d68e52..67e940b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +16,8 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore<AppPreferences>, ) { + val flow = preferencesDataStore.data.map { UserPreferences(it) } + suspend fun getCurrent(): UserPreferences = serverRepository.currentUserDto.value!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() 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 52c37413..a44ab65b 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 @@ -10,6 +10,7 @@ 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.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -17,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -48,6 +50,14 @@ annotation class IoCoroutineScope @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -61,12 +71,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -111,12 +115,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = OkHttpFactory(okHttpClient) + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) @Provides @Singleton fun jellyfin( - okHttpFactory: OkHttpFactory, + okHttpFactory: CoroutineContextApiClientFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, @@ -167,7 +171,7 @@ object AppModule { if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { scope.launch(ExceptionHandler()) { appPreference.updateData { - preferences.appPreferences.updateInterfacePreferences { + it.updateInterfacePreferences { putRememberedTabs(key(itemId), tabIndex) } } @@ -176,15 +180,29 @@ object AppModule { } } + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @Provides @Singleton @@ -198,3 +216,13 @@ object AppModule { @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) } + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) +} 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 c997efd1..865c6d29 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 @@ -5,7 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + * Tries to find the [Activity] for the given [Context] */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? { return null } -/** - * Keep the screen on for an [Activity]. Often used with [findActivity]. - */ -fun Activity.keepScreenOn(keep: Boolean) { - Timber.v("Keep screen on: $keep") - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - /** * Selectively log errors from Coil image loading. * @@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } -@Composable -fun rememberBackDropImage(item: BaseItem): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } -} - /** * Check if this, coalescing nulls to zero, is greater than that */ 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 1842620a..8f4066bb 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 @@ -5,6 +5,7 @@ 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 com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber @@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String? append(productionYear.toString()) if (status == "Continuing") { append(" - ") - append("Present") + append(WholphinApplication.instance.getString(R.string.series_continueing)) } else if (status == "Ended") { endDate?.let { if (it.year != productionYear) { 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 2d9b6d5e..ab0f69f8 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 @@ -58,6 +58,7 @@ val DefaultItemFields = ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) /** @@ -72,6 +73,7 @@ val SlimItemFields = ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) val PhotoItemFields = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 294df8f6..51d003fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -68,6 +68,7 @@ fun BannerCard( interactionSource: MutableInteractionSource? = null, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = true, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -82,7 +83,7 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight, imageType) { + remember(item, fillHeight, imageType, useSeriesForPrimary) { if (item != null) { item.imageUrlOverride ?: imageUrlService.getItemImageUrl( @@ -90,6 +91,7 @@ fun BannerCard( imageType, fillWidth = null, fillHeight = fillHeight, + useSeriesForPrimary = useSeriesForPrimary, ) } else { null @@ -208,6 +210,7 @@ fun BannerCardWithTitle( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) @@ -234,6 +237,7 @@ fun BannerCardWithTitle( interactionSource = interactionSource, imageType = imageType, imageContentScale = imageContentScale, + useSeriesForPrimary = useSeriesForPrimary, ) Column( verticalArrangement = Arrangement.spacedBy(0.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 7ebf0faf..314a7e1a 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,7 +58,7 @@ fun <T> ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt new file mode 100644 index 00000000..fe4ae115 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -0,0 +1,214 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +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.Box +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.layout.size +import androidx.compose.runtime.Composable +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.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +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 coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ScreensaverViewModel + @Inject + constructor( + private val screensaverService: ScreensaverService, + val preferencesDataStore: DataStore<AppPreferences>, + ) : ViewModel() { + val currentItem = screensaverService.createItemFlow(viewModelScope) + } + +data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, +) + +@Composable +fun AppScreensaver( + prefs: AppPreferences, + modifier: Modifier = Modifier, + viewModel: ScreensaverViewModel = hiltViewModel(), +) { + val currentItem by viewModel.currentItem.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = prefs.interfacePreferences.screensaverPreference.showClock, + duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, + animate = prefs.interfacePreferences.screensaverPreference.animate, + modifier = modifier, + ) +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun AppScreensaverContent( + currentItem: CurrentItem?, + showClock: Boolean, + duration: Duration, + animate: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier + .background(Color.Black), + ) { + val infiniteTransition = rememberInfiniteTransition() + val scale by infiniteTransition.animateFloat( + 1f, + if (animate) 1.1f else 1f, + infiniteRepeatable( + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + delayMillis = 500, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.backdropUrl) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + + var logoError by remember(currentItem) { mutableStateOf(false) } + if (!logoError) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.logoUrl) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .build(), + contentDescription = "Logo", + onError = { + logoError = true + }, + modifier = + Modifier + .align(Alignment.BottomStart) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(Alignment.BottomStart) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.BottomStart), + ) + } + } + + val largeRadialGradient = + remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val biggerDimension = maxOf(size.height, size.width) + return RadialGradientShader( + colors = listOf(Color.Transparent, AppColors.TransparentBlack25), + center = size.center, + radius = biggerDimension / 1.5f, + colorStops = listOf(0f, 0.85f), + ) + } + } + } + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = largeRadialGradient, + blendMode = BlendMode.Multiply, + ) + if (showClock) { + // Add scrim to make clock more readable + 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, + ) + } + } + if (showClock) { + TimeDisplay() + } + } +} 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 4c672998..a4e4ef22 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 @@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +33,7 @@ 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.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -55,11 +57,16 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +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.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -72,15 +79,19 @@ 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.equalsNotNull +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -93,6 +104,9 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -120,7 +134,10 @@ class CollectionFolderViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -157,9 +174,10 @@ class CollectionFolderViewModel viewModelScope.launchIO { super.itemId = itemId try { - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } val libraryDisplayInfo = serverRepository.currentUser.value?.let { user -> @@ -184,11 +202,42 @@ class CollectionFolderViewModel } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") loading.setValueOnMain(DataLoadingState.Error(ex)) } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) + } + } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", itemId) + showToast(context, "Error refreshing after item deleted") + } } private fun saveLibraryDisplayInfo( @@ -254,34 +303,32 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) } } } @@ -443,6 +490,46 @@ class CollectionFolderViewModel backdropService.submit(item) } } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + item.value?.let { + Timber.v("onResumePage: %s", loading.value!!::class) + if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + val volume = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(it.id, volume) + } + } + } + } + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } /** @@ -530,6 +617,7 @@ fun CollectionFolderGrid( var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { @@ -548,6 +636,13 @@ fun CollectionFolderGrid( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Box(modifier = modifier) { + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } CollectionFolderGridContent( preferences = preferences, initialPosition = viewModel.position, @@ -598,7 +693,7 @@ fun CollectionFolderGrid( } else { Destination.Playback(item) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { @@ -622,7 +717,7 @@ fun CollectionFolderGrid( filter = filter, ) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) } }, ) @@ -658,9 +753,10 @@ fun CollectionFolderGrid( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, + navigateTo = { viewModel.navigateTo(it) }, onClickWatch = { itemId, watched -> viewModel.setWatched(position, itemId, watched) }, @@ -672,6 +768,12 @@ fun CollectionFolderGrid( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = PositionItem(position, item) + }, + onClickGoTo = { + onClickItem.invoke(position, it) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -696,8 +798,19 @@ fun CollectionFolderGrid( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CollectionFolderGridContent( preferences: UserPreferences, @@ -760,8 +873,8 @@ fun CollectionFolderGridContent( ) { AnimatedVisibility( showHeader || loadingState !is DataLoadingState.Success, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -842,14 +955,16 @@ fun CollectionFolderGridContent( } } } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { HomePageHeader( item = focusedItem, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .padding(16.dp), + .height(200.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp), ) } when (val state = loadingState) { @@ -857,7 +972,7 @@ fun CollectionFolderGridContent( DataLoadingState.Loading, -> { // This shouldn't happen, so just show placeholder - Text("Loading") + Text(stringResource(R.string.loading)) } is DataLoadingState.Error -> { @@ -895,6 +1010,15 @@ fun CollectionFolderGridContent( }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, + bringIntoViewSpec = + remember(viewOptions) { + val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } + if (viewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, ) AnimatedVisibility(showViewOptions) { ViewOptionsDialog( 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 579d9a97..82aab6ba 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 @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +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.BoxScope @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -56,8 +64,12 @@ 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.FontAwesome +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,31 +271,65 @@ fun DialogPopupContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { if (dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == 0, + Modifier.onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(dialogItems.lastIndex) + focusRequesters[dialogItems.lastIndex].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + index == dialogItems.lastIndex, + Modifier.onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + focusRequesters[0].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ), ) } } @@ -471,6 +517,80 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + fun chooseVersionParams( context: Context, sources: List<MediaSourceInfo>, @@ -512,6 +632,7 @@ fun chooseStream( streams: List<MediaStream>, currentIndex: Int?, type: MediaStreamType, + preferredSubtitleLanguage: String?, onClick: (Int) -> Unit, ): DialogParams = DialogParams( @@ -536,6 +657,9 @@ fun chooseStream( ) add( DialogItem( + leadingContent = { + SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) + }, headlineContent = { Text(text = stringResource(R.string.only_forced_subtitles)) }, @@ -546,22 +670,32 @@ fun chooseStream( ) } addAll( - streams.filter { it.type == type }.mapIndexed { index, stream -> - val simpleStream = SimpleMediaStream.from(context, stream, true) - DialogItem( - selected = currentIndex == stream.index, - leadingContent = { - SelectedLeadingContent(currentIndex == stream.index) - }, - headlineContent = { - Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) - }, - supportingContent = { - if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) - }, - onClick = { onClick.invoke(stream.index) }, - ) - }, + streams + .filter { it.type == type } + .let { + if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) { + it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage } + } else { + it + } + }.mapIndexed { index, stream -> + val simpleStream = SimpleMediaStream.from(context, stream, true) + DialogItem( + selected = currentIndex == stream.index, + leadingContent = { + SelectedLeadingContent(currentIndex == stream.index) + }, + headlineContent = { + 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/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 84f36f02..d42eea5c 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 @@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -30,13 +29,13 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus 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 com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -57,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours @HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel @@ -112,6 +113,7 @@ class GenreViewModel val genreToUrl = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = viewModelScope, imageUrlService = imageUrlService, genres = genres.map { it.id }, @@ -143,15 +145,35 @@ class GenreViewModel } } +data class GenreCacheKey( + val userId: UUID?, + val parentId: UUID, +) + +private val genreCache by lazy { + InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) { + expireAfterWriteDuration = 2.hours + } +} + suspend fun getGenreImageMap( api: ApiClient, + userId: UUID?, scope: CoroutineScope, imageUrlService: ImageUrlService, genres: List<UUID>, parentId: UUID, includeItemTypes: List<BaseItemKind>?, cardWidthPx: Int?, + useCache: Boolean = true, ): Map<UUID, String?> { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } val genreToUrl = ConcurrentHashMap<UUID, String?>() val semaphore = Semaphore(4) genres @@ -163,6 +185,7 @@ suspend fun getGenreImageMap( .execute( api, GetItemsRequest( + userId = userId, parentId = parentId, recursive = true, limit = 1, @@ -186,11 +209,13 @@ suspend fun getGenreImageMap( imageType = ImageType.BACKDROP, imageTags = item.imageTags.orEmpty(), fillWidth = cardWidthPx, + backdropTags = item.backdropImageTags.orEmpty(), ) } } } }.awaitAll() + genreCache.put(key, genreToUrl) return genreToUrl } @@ -256,23 +281,12 @@ fun GenreCardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( - Destination.FilteredCollection( - itemId = itemId, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genre.name, - item?.title, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genre.id), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, ), ) }, 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 17b84dee..75a3147c 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 @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween 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.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource @@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -65,12 +72,14 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, trailers: List<Trailer>?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, + deleteOnClick: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -151,6 +160,16 @@ fun ExpandablePlayButtons( ) } } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } // More button item("more") { @@ -234,7 +253,7 @@ fun ExpandablePlayButton( fun ExpandablePlayButton( @StringRes title: Int, resume: Duration, - icon: @Composable () -> Unit, + icon: @Composable BoxScope.() -> Unit, onClick: (position: Duration) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -254,12 +273,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - icon.invoke() + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -359,6 +379,48 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { onClick.invoke() }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -374,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() { buttonOnFocusChanged = {}, trailers = listOf(), trailerOnClick = {}, + canDelete = true, + deleteOnClick = {}, modifier = Modifier, ) } @@ -417,12 +481,19 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onClick = {}, + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onClick = {}, + interactionSource = source, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 908574e3..c3d1a4b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +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.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn @@ -31,6 +34,7 @@ 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.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -51,6 +55,7 @@ abstract class RecommendedViewModel( val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { abstract fun init() @@ -117,6 +122,25 @@ abstract class RecommendedViewModel( } update(title, row) } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } @Composable @@ -130,6 +154,7 @@ fun RecommendedContent( val context = LocalContext.current var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { @@ -196,6 +221,7 @@ fun RecommendedContent( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigationManager.navigateTo(it) }, @@ -210,6 +236,7 @@ fun RecommendedContent( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -234,9 +261,19 @@ fun RecommendedContent( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } -private data class RowColumnItem( +data class RowColumnItem( val position: RowColumn, val item: BaseItem, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 403ff5fa..392c77a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -14,6 +14,7 @@ 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.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -63,12 +64,14 @@ class RecommendedMovieViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { 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 3258930c..db7f7b4f 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -67,12 +68,14 @@ class RecommendedTvShowViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt index 009c4268..9e2141b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/AddPlaylistViewModel.kt @@ -5,6 +5,7 @@ import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.launchIO @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -43,7 +45,13 @@ class AddPlaylistViewModel itemId: UUID, ) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { - playlistCreator.addToServerPlaylist(playlistId, itemId) + try { + playlistCreator.addToServerPlaylist(playlistId, itemId) + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) + } catch (ex: Exception) { + Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId) + showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT) + } } } @@ -55,6 +63,8 @@ class AddPlaylistViewModel val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId)) if (playlistId == null) { showToast(context, "Error creating playlist", Toast.LENGTH_LONG) + } else { + showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT) } } } 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 8893246c..ac72a144 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 @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 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 } - } - }, - ) { - 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}", - ) + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + }, + ) { + 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}", + ) + } } } } 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 dc426409..0d9bd338 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 @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -127,8 +125,8 @@ fun CollectionFolderLiveTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, 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 96ddbe94..6f67b011 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 @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -76,8 +74,8 @@ fun CollectionFolderMovie( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt index fd4d46d3..562af214 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -59,13 +59,13 @@ fun CollectionFolderPhotoAlbum( index = index, filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, - recursive = true, + recursive = recursive, startSlideshow = false, ) } else { item.destination(index) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, itemId = itemId.toServerString(), initialFilter = filter, 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 d72001ba..68b9dd18 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 @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -80,8 +78,8 @@ fun CollectionFolderTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, 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 cebfff70..5fe13871 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 @@ -29,6 +29,8 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, + val onClickDelete: (BaseItem) -> Unit, + val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) }, ) enum class ClearChosenStreams { @@ -62,6 +64,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, + canDelete: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -139,6 +142,17 @@ fun buildMoreDialogItems( actions.onClickAddPlaylist.invoke(item.id) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -223,6 +237,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, actions: MoreDialogActions, ): List<DialogItem> = buildList { @@ -232,7 +247,7 @@ fun buildMoreDialogItemsForHome( context.getString(R.string.go_to), Icons.Default.ArrowForward, ) { - actions.navigateTo(item.destination()) + actions.onClickGoTo(item) }, ) if (item.type in supportedPlayableTypes) { @@ -290,6 +305,17 @@ fun buildMoreDialogItemsForHome( actions.onClickAddPlaylist.invoke(itemId) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, 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 fc337d88..50b0053e 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 @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -98,8 +96,8 @@ fun FavoritesPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, 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 index 0c7d08b9..a366e788 100644 --- 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 @@ -60,7 +60,6 @@ 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 @@ -102,15 +101,6 @@ fun DiscoverMovieDetails( 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 -> }, - onSendMediaInfo = {}, - ) - when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) 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 index a50e848d..3edd7dd6 100644 --- 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 @@ -103,7 +103,7 @@ class DiscoverMovieViewModel ) { Timber.v("Init for movie %s", item.id) val movie = fetchAndSetItem().await() - val discoveredItem = DiscoverItem(movie) + val discoveredItem = seerrService.createDiscoverItem(movie) backdropService.submit(discoveredItem) updateCanCancel() @@ -121,7 +121,7 @@ class DiscoverMovieViewModel seerrService.api.moviesApi .movieMovieIdSimilarGet(movieId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() similar.setValueOnMain(result) } @@ -130,7 +130,7 @@ class DiscoverMovieViewModel seerrService.api.moviesApi .movieMovieIdRecommendationsGet(movieId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() recommended.setValueOnMain(result) } @@ -138,11 +138,11 @@ class DiscoverMovieViewModel val people = movie.credits ?.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() + movie.credits ?.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() this@DiscoverMovieViewModel.people.setValueOnMain(people) val trailers = 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 index 32d38aa5..f7d50f31 100644 --- 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 @@ -67,11 +67,11 @@ class DiscoverPersonViewModel .let { credits -> val cast = credits.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() val crew = credits.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() cast + crew } 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 index 3c036534..12acfd37 100644 --- 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 @@ -35,7 +35,6 @@ 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 @@ -99,6 +98,7 @@ fun DiscoverSeriesDetails( var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) } + var showRequestSeasonDialog by remember { mutableStateOf(false) } val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) @@ -159,26 +159,7 @@ fun DiscoverSeriesDetails( trailers = trailers, 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) - } + showRequestSeasonDialog = true } }, cancelOnClick = { @@ -218,6 +199,18 @@ fun DiscoverSeriesDetails( waitToLoad = params.fromLongClick, ) } + if (showRequestSeasonDialog) { + RequestSeasonsDialog( + title = item?.name ?: "", + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = { seasons, is4k -> + item?.id?.let { viewModel.request(it, seasons, is4k) } + showRequestSeasonDialog = false + }, + onDismissRequest = { showRequestSeasonDialog = false }, + ) + } } private const val HEADER_ROW = 0 @@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent( series: TvDetails, rating: DiscoverRating?, canCancel: Boolean, - seasons: List<Season>, + seasons: List<RequestSeason>, similar: List<DiscoverItem>, recommended: List<DiscoverItem>, trailers: List<Trailer>, @@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent( SeerrAvailability.from(series.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, requestOnClick = requestOnClick, + pendingOnClick = requestOnClick, cancelOnClick = cancelOnClick, moreOnClick = moreOnClick, goToOnClick = goToOnClick, 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 index f7b11137..54b3d98f 100644 --- 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 @@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo 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.RequestRequestIdPutRequest 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.RemoteTrailer +import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager @@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update @@ -63,7 +65,7 @@ class DiscoverSeriesViewModel val tvSeries = MutableLiveData<TvDetails?>(null) val rating = MutableLiveData<DiscoverRating?>(null) - val seasons = MutableLiveData<List<Season>>(listOf()) + val seasons = MutableLiveData<List<RequestSeason>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf()) val similar = MutableLiveData<List<DiscoverItem>>() @@ -100,9 +102,10 @@ class DiscoverSeriesViewModel ) { Timber.v("Init for tv %s", item.id) val tv = fetchAndSetItem().await() - val discoveredItem = DiscoverItem(tv) + val discoveredItem = seerrService.createDiscoverItem(tv) backdropService.submit(discoveredItem) + updateSeasonStatus() updateCanCancel() withContext(Dispatchers.Main) { @@ -118,7 +121,7 @@ class DiscoverSeriesViewModel seerrService.api.tvApi .tvTvIdSimilarGet(tvId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() similar.setValueOnMain(result) } @@ -127,7 +130,7 @@ class DiscoverSeriesViewModel seerrService.api.tvApi .tvTvIdRecommendationsGet(tvId = item.id, page = 1) .results - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() recommended.setValueOnMain(result) } @@ -135,11 +138,11 @@ class DiscoverSeriesViewModel val people = tv.credits ?.cast - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() + tv.credits ?.crew - ?.map(::DiscoverItem) + ?.map { seerrService.createDiscoverItem(it) } .orEmpty() this@DiscoverSeriesViewModel.people.setValueOnMain(people) @@ -157,6 +160,43 @@ class DiscoverSeriesViewModel navigationManager.navigateTo(destination) } + private suspend fun updateSeasonStatus() { + tvSeries.value?.let { tv -> + val seasonStatus = mutableMapOf<Int, SeerrAvailability>() + tv.seasons?.forEach { + it.seasonNumber?.let { + seasonStatus[it] = SeerrAvailability.UNKNOWN + } + } + val tvStatus = + SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN + tv.mediaInfo + ?.requests + ?.forEach { + it.seasons?.mapNotNull { season -> + season.seasonNumber?.let { + val current = seasonStatus[season.seasonNumber] + val new = + SeerrAvailability + .from(season.status) + ?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus + if (current == null || new.status > current.status) { + seasonStatus[season.seasonNumber] = new + } + } + } + } + Timber.v("seasonStatus=%s", seasonStatus) + val requestSeasons = + seasonStatus.mapNotNull { (seasonNumber, availability) -> + tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let { + RequestSeason(it, availability) + } + } + seasons.setValueOnMain(requestSeasons) + } + } + private suspend fun updateCanCancel() { val user = userConfig.firstOrNull() val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) @@ -165,20 +205,43 @@ class DiscoverSeriesViewModel fun request( id: Int, + seasons: Set<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() + tvSeries.value?.let { tv -> + val currentRequest = + tv.mediaInfo?.requests?.firstOrNull { + it.requestedBy?.id == + seerrServerRepository.currentUserId.first() + } + if (currentRequest != null) { + Timber.v("User has pending request, will update") + seerrService.api.requestApi.requestRequestIdPut( + requestId = currentRequest.id.toString(), + requestRequestIdPutRequest = + RequestRequestIdPutRequest( + is4k = is4k, + mediaType = RequestRequestIdPutRequest.MediaType.TV, + seasons = seasons.toList(), + ), + ) + } else { + Timber.v("New request for %s seasons", seasons.size) + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = is4k, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = seasons.toList(), + ), + ) + } + + fetchAndSetItem().await() + updateSeasonStatus() + 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 index 4b73a0b2..2c3fc036 100644 --- 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 @@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons( trailerOnClick: (Trailer) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, + pendingOnClick: () -> Unit = {}, ) { val firstFocus = remember { FocusRequester() } LazyRow( @@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons( SeerrAvailability.PENDING, SeerrAvailability.PROCESSING, -> { - // TODO? + pendingOnClick.invoke() } SeerrAvailability.PARTIALLY_AVAILABLE, @@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons( .onFocusChanged(buttonOnFocusChanged), ) } + if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) { + item("request_partial") { + ExpandableFaButton( + title = R.string.request, + iconStringRes = R.string.fa_download, + onClick = { + requestOnClick.invoke() + }, + enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } if (canCancel) { item("cancel") { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt new file mode 100644 index 00000000..4a56cb88 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/RequestSeasons.kt @@ -0,0 +1,323 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION +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.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableStateSetOf +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.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import androidx.tv.material3.contentColorFor +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.ui.cards.AvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator +import com.github.damontecres.wholphin.ui.cards.PendingIndicator +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class RequestSeason( + val season: Season, + val availability: SeerrAvailability, +) + +@Composable +fun RequestSeasons( + title: String, + seasons: List<RequestSeason>, + onSubmit: (Set<Int>, Boolean) -> Unit, + request4kEnabled: Boolean, + modifier: Modifier, +) { + val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() } + val selected = + remember { + mutableStateSetOf<Int>( + *seasons + .mapNotNull { + if (it.availability > SeerrAvailability.UNKNOWN) { + it.season.seasonNumber + } else { + null + } + }.toTypedArray(), + ) + } + var is4k by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + LazyColumn { + item { + val isSelected = selected.containsAll(allSeasonNumbers) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + ClickSwitch( + label = stringResource(R.string.select_all), + checked = isSelected, + onClick = { + if (isSelected) { + selected.removeAll(allSeasonNumbers) + } else { + selected.addAll(allSeasonNumbers) + } + }, + ) + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + if (request4kEnabled) { + item { + ClickSwitch( + label = stringResource(R.string.request_4k), + checked = is4k, + onClick = { is4k = !is4k }, + ) + } + } + itemsIndexed(seasons) { index, season -> + val seasonNumber = season.season.seasonNumber + val isSelected = seasonNumber in selected + SeasonListItem( + season = season, + selected = isSelected, + onClick = { + if (isSelected) { + selected.remove(seasonNumber) + } else { + seasonNumber?.let { selected.add(it) } + } + }, + modifier = Modifier, + ) + } + if (seasons.size > 7) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + ) { + Button( + onClick = { onSubmit.invoke(selected, is4k) }, + ) { + Text( + text = stringResource(R.string.submit), + ) + } + } + } + } + } + } +} + +@Composable +fun SeasonListItem( + season: RequestSeason, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ListItem( + selected = false, + headlineContent = { + Text( + text = + season.season.name + ?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"), + ) + }, + supportingContent = { + season.season.episodeCount?.let { + Text( + // TODO should use plurals string + text = "${season.season.episodeCount} " + stringResource(R.string.episodes), + ) + } + }, + leadingContent = { + when (season.availability) { + SeerrAvailability.UNKNOWN -> {} + + SeerrAvailability.DELETED -> {} + + SeerrAvailability.PENDING, + SeerrAvailability.PROCESSING, + -> { + PendingIndicator() + } + + SeerrAvailability.PARTIALLY_AVAILABLE -> { + PartiallyAvailableIndicator() + } + + SeerrAvailability.AVAILABLE -> { + AvailableIndicator() + } + } + }, + trailingContent = { + Row { + Switch( + checked = selected, + onCheckedChange = { + onClick.invoke() + }, + ) + } + }, + onClick = onClick, + modifier = modifier, + ) +} + +@Composable +private fun ClickSurface( + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable BoxScope.() -> Unit, +) { + Surface( + colors = + ClickableSurfaceDefaults.colors( + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContainerColor = MaterialTheme.colorScheme.inverseSurface, + focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + pressedContainerColor = MaterialTheme.colorScheme.inverseSurface, + pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface), + ), + onClick = onClick, + content = content, + modifier = modifier, + ) +} + +@Composable +private fun ClickSwitch( + label: String, + checked: Boolean, + onClick: () -> Unit, +) { + ClickSurface( + onClick = onClick, + modifier = Modifier, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .padding(horizontal = 8.dp) + .height(54.dp), + ) { + Switch( + checked = checked, + onCheckedChange = {}, + modifier = Modifier.padding(end = 8.dp), + ) + Text( + text = label, + ) + } + } +} + +@Composable +fun RequestSeasonsDialog( + title: String, + seasons: List<RequestSeason>, + request4kEnabled: Boolean, + onSubmit: (Set<Int>, Boolean) -> Unit, + onDismissRequest: () -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + RequestSeasons( + title = title, + seasons = seasons, + request4kEnabled = request4kEnabled, + onSubmit = onSubmit, + modifier = Modifier.padding(16.dp), + ) + } +} + +@Preview( + device = "spec:parent=tv_1080p", + backgroundColor = 0xFF383535, + uiMode = UI_MODE_TYPE_TELEVISION, + heightDp = 800, +) +@Composable +fun RequestSeasonsPreview() { + val seasons = + List(10) { + RequestSeason( + season = + Season( + seasonNumber = it + 1, + episodeCount = 10 + it, + ), + availability = + if (it < 3) { + SeerrAvailability.AVAILABLE + } else { + SeerrAvailability.UNKNOWN + }, + ) + } + + WholphinTheme { + RequestSeasons( + title = "Series title", + seasons = seasons, + request4kEnabled = true, + onSubmit = { _, _ -> }, + modifier = Modifier.width(400.dp), + ) + } +} 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 63d5ee5d..e341923b 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 @@ -30,6 +30,7 @@ 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.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -82,8 +83,16 @@ fun EpisodeDetails( var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference + val moreActions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -98,6 +107,7 @@ fun EpisodeDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -197,6 +207,7 @@ fun EpisodeDetails( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, @@ -215,6 +226,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, + canDelete = viewModel.canDelete, ), ) }, @@ -224,6 +236,8 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) } @@ -276,6 +290,16 @@ fun EpisodeDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -290,6 +314,8 @@ fun EpisodeDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -347,6 +373,8 @@ fun EpisodeDetailsContent( }, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() 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 7808141e..9b8bda5c 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 @@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -54,6 +56,7 @@ class EpisodeViewModel private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -65,6 +68,9 @@ class EpisodeViewModel val item = MutableLiveData<BaseItem?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null) + var canDelete: Boolean = false + private set + init { init() } @@ -95,6 +101,7 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() + canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item @@ -199,4 +206,10 @@ class EpisodeViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } 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 037a5ae7..30309304 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 @@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -140,7 +142,11 @@ fun TvGuideGrid( .fillMaxHeight(.30f), ) } - AnimatedVisibility(focusedPosition.row < 1) { + AnimatedVisibility( + focusedPosition.row < 1, + enter = expandVertically(), + exit = shrinkVertically(), + ) { ExpandableFaButton( title = R.string.view_options, iconStringRes = R.string.fa_sliders, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 2a40a399..87a5dd75 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -111,6 +112,14 @@ fun MovieDetails( var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } + + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference val moreActions = MoreDialogActions( @@ -126,6 +135,7 @@ fun MovieDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -201,6 +211,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -242,6 +253,7 @@ fun MovieDetails( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, @@ -291,6 +303,7 @@ fun MovieDetails( playbackPosition = similar.playbackPosition, watched = similar.played, favorite = similar.favorite, + canDelete = false, actions = moreActions, ) moreDialog = @@ -310,6 +323,8 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) } @@ -362,6 +377,16 @@ fun MovieDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -395,6 +420,8 @@ fun MovieDetailsContent( onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -457,6 +484,8 @@ fun MovieDetailsContent( position = TRAILER_ROW trailerOnClick.invoke(it) }, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() 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 ed46656c..993260dc 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 @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume 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.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -26,6 +27,7 @@ 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.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -73,6 +75,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -90,6 +93,9 @@ class MovieViewModel val chosenStreams = MutableLiveData<ChosenStreams?>(null) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + var canDelete: Boolean = false + private set + init { init() } @@ -106,6 +112,7 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } + canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } @@ -274,4 +281,10 @@ class MovieViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt index 6717527e..09df419c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -91,10 +92,25 @@ fun SearchForContent( } } } + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + val title = titleRes?.let { stringResource(it) } ?: "" Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { + Text( + text = stringResource(R.string.search_for, title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), @@ -190,16 +206,8 @@ fun SearchForContent( text = stringResource(R.string.no_results), ) } else { - val titleRes = - remember { - when (searchType) { - BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.PLAYLIST -> R.string.playlists - else -> null - } - } ItemRow( - title = titleRes?.let { stringResource(it) } ?: "", + title = "", items = st.items, onClickItem = { _, item -> onClick.invoke(item) }, onLongClickItem = { _, _ -> }, 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 9b6d81df..2aeac04e 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 @@ -25,6 +25,8 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -47,6 +49,8 @@ fun FocusedEpisodeFooter( buttonOnFocusChanged = buttonOnFocusChanged, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, 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 ff0ddade..aab6e056 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusState @@ -31,17 +32,17 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier) + EpisodeName(dto, modifier = Modifier.padding(start = 8.dp)) ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime) + QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp)) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } OverviewText( 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 f0c74be3..4f8e4366 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 @@ -1,6 +1,9 @@ package com.github.damontecres.wholphin.ui.detail.series import android.content.Context +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -14,6 +17,7 @@ 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.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -24,12 +28,14 @@ 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.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.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +59,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.DeleteButton import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -76,6 +84,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -102,21 +111,26 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() + val canDelete by viewModel.canDeleteSeries.collectAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) 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() + val discoverSeries by viewModel.discoverSeries.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } when (val state = loading) { is LoadingState.Error -> { @@ -150,6 +164,7 @@ fun SeriesDetails( similar = similar, played = played, favorite = item.data.userData?.isFavorite ?: false, + canDelete = canDelete, modifier = modifier, onClickItem = { index, item -> viewModel.navigateTo(item.destination()) @@ -163,23 +178,29 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + scope.launchDefault { + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + canDelete = viewModel.canDelete(season), + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + onClickDelete = { + showDeleteDialog = it + }, + ) + } }, overviewOnClick = { overviewDialog = @@ -213,6 +234,12 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discoverSeries = discoverSeries, + onClickDiscoverSeries = { + discoverSeries?.let { + viewModel.navigateTo(Destination.DiscoveredItem(it)) + } + }, discovered = discovered, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) @@ -231,6 +258,9 @@ fun SeriesDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), ) if (showWatchConfirmation) { @@ -283,6 +313,19 @@ fun SeriesDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + if (seasons?.lastOrNull()?.id == item.id) { + focusManager.moveFocus(FocusDirection.Previous) + } + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -305,6 +348,7 @@ fun SeriesDetailsContent( discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -316,6 +360,8 @@ fun SeriesDetailsContent( onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, onClickDiscover: (Int, DiscoverItem) -> Unit, + discoverSeries: DiscoverItem?, + onClickDiscoverSeries: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -436,6 +482,42 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + onClick = { + position = HEADER_ROW + moreActions.onClickDelete.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + AnimatedVisibility( + visible = discoverSeries != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExpandableFaButton( + title = R.string.discover, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = onClickDiscoverSeries, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -533,6 +615,7 @@ fun SeriesDetailsContent( watched = item.played, favorite = item.favorite, actions = moreActions, + canDelete = false, ) moreDialog = DialogParams( @@ -621,7 +704,7 @@ fun SeriesDetailsHeader( ) { QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( @@ -638,9 +721,11 @@ fun SeriesDetailsHeader( fun buildDialogForSeason( context: Context, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { @@ -679,6 +764,17 @@ fun buildDialogForSeason( onClickPlay.invoke(true) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), 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 8fba8ed6..84180f20 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 @@ -9,6 +9,7 @@ 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 @@ -23,6 +24,7 @@ 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.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,9 +38,11 @@ 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.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.launchDefault 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 @@ -89,6 +93,7 @@ fun SeriesOverview( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } @@ -118,20 +123,7 @@ fun SeriesOverview( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() - - LaunchedEffect(episodes) { - episodes?.let { episodes -> - if (episodes is EpisodeList.Success) { - if (episodes.episodes.isNotEmpty()) { - // TODO focus on first episode when changing seasons? -// firstItemFocusRequester.requestFocus() - episodes.episodes.getOrNull(position.episodeRowIndex)?.let { - viewModel.refreshEpisode(it.id, position.episodeRowIndex) - } - } - } - } - } + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } LaunchedEffect(position, episodes) { val focusedEpisode = @@ -146,6 +138,13 @@ fun SeriesOverview( } val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val preferredSubtitleLanguage = + viewModel.serverRepository.currentUserDto + .observeAsState() + .value + ?.configuration + ?.subtitleLanguagePreference + when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) @@ -177,7 +176,7 @@ fun SeriesOverview( } } - fun buildMoreForEpisode( + suspend fun buildMoreForEpisode( ep: BaseItem, chosenStreams: ChosenStreams?, fromLongClick: Boolean, @@ -194,6 +193,7 @@ fun SeriesOverview( seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete(ep), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -216,6 +216,9 @@ fun SeriesOverview( showPlaylistDialog = it }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), onChooseVersion = { chooseVersion = @@ -256,6 +259,7 @@ fun SeriesOverview( type, ) }, + preferredSubtitleLanguage = preferredSubtitleLanguage, ) } }, @@ -315,7 +319,9 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + } }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -343,18 +349,22 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + } } }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) + scope.launchDefault { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + genres = it.data.genres.orEmpty(), + files = it.data.mediaSources.orEmpty(), + ) + } } }, personOnClick = { @@ -367,6 +377,8 @@ fun SeriesOverview( ), ) }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + deleteOnClick = { showDeleteDialog = it }, modifier = modifier, ) } @@ -420,6 +432,17 @@ fun SeriesOverview( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.subtitle ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + episodeRowFocusRequester.tryRequestFocus() + showDeleteDialog = null + }, + ) + } } private const val EPISODE_ROW = 0 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 86e2b398..8acc32f1 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 @@ -88,6 +88,8 @@ fun SeriesOverviewContent( moreOnClick: () -> Unit, overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, + canDelete: (BaseItem) -> Boolean, + deleteOnClick: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -134,7 +136,7 @@ fun SeriesOverviewContent( .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() @@ -159,9 +161,10 @@ fun SeriesOverviewContent( Modifier .focusRequester(tabRowFocusRequester) .padding(paddingValues) + .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier) + SeriesName(series.name, Modifier.padding(start = 8.dp)) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, @@ -266,6 +269,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } @@ -292,10 +296,12 @@ fun SeriesOverviewContent( } } }, + canDelete = canDelete.invoke(ep), + deleteOnClick = { deleteOnClick.invoke(ep) }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } 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 3d80aa9d..2d044670 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 @@ -13,9 +13,11 @@ 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.AppPreferences 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.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -24,10 +26,12 @@ 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.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt @@ -53,6 +57,10 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -90,6 +98,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -111,9 +120,11 @@ class SeriesViewModel val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val people = MutableLiveData<List<Person>>(listOf()) val similar = MutableLiveData<List<BaseItem>>() + val canDeleteSeries = MutableStateFlow(false) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + val discoverSeries = MutableStateFlow<DiscoverItem?>(null) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -127,6 +138,7 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) + canDeleteSeries.update { mediaManagementService.canDelete(item) } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -222,7 +234,39 @@ class SeriesViewModel val results = seerrService.similar(item).orEmpty() discovered.update { results } } + viewModelScope.launchIO { + seerrService.active.collectLatest { active -> + val tv = + if (active) { + try { + seerrService + .getTvSeries(item) + ?.let { seerrService.createDiscoverItem(it) } + } catch (ex: Exception) { + Timber.e(ex) + null + } + } else { + null + } + discoverSeries.update { tv } + } + } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + if (deletedItem.item.data.seriesId == seriesId) { + Timber.d( + "Item %s deleted from series %s", + deletedItem.item.id, + seriesId, + ) + val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) } } @@ -259,9 +303,12 @@ class SeriesViewModel if (seriesPageType == SeriesPageType.DETAILS) { listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -300,6 +347,7 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ), ) Timber.v( @@ -341,12 +389,6 @@ class SeriesViewModel withContext(Dispatchers.Main) { this@SeriesViewModel.episodes.value = episodes } - if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) { - (episodes as? EpisodeList.Success) - ?.let { - it.episodes.getOrNull(it.initialEpisodeIndex) - }?.let { lookupPeopleInEpisode(it) } - } } } @@ -528,7 +570,7 @@ class SeriesViewModel api.userLibraryApi .getItem(item.id) .content.people - ?.map { Person.fromDto(it, api) } + ?.map { Person.fromDto(context, it, api) } .orEmpty() PeopleInItem(item.id, list) @@ -551,6 +593,64 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + this@SeriesViewModel.item.value?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = episodes.value as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { 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 index 53b04161..5c18a0ea 100644 --- 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 @@ -1,10 +1,8 @@ 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.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -60,8 +58,8 @@ fun DiscoverPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, 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 index 85863650..6ed79a06 100644 --- 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 @@ -85,13 +85,13 @@ class SeerrRequestsViewModel seerrService.api.moviesApi .movieMovieIdGet( movieId = request.media.tmdbId, - ).let { DiscoverItem(it) } + ).let { seerrService.createDiscoverItem(it) } } SeerrItemType.TV -> { seerrService.api.tvApi .tvTvIdGet(tvId = request.media.tmdbId) - .let { DiscoverItem(it) } + .let { seerrService.createDiscoverItem(it) } } SeerrItemType.PERSON -> { 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 b9a7cbd7..3a7a457a 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 @@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.RowColumnItem import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -116,6 +118,7 @@ fun HomePage( LoadingState.Success -> { var dialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var position by rememberPosition() HomePageContent( @@ -136,6 +139,7 @@ fun HomePage( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -150,6 +154,9 @@ fun HomePage( showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = RowColumnItem(position, item) + }, ), ) dialog = @@ -190,6 +197,16 @@ fun HomePage( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } } } @@ -524,6 +541,7 @@ fun HomePageCardContent( onLongClick = onLongClick, modifier = modifier, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } else { BannerCard( @@ -543,6 +561,7 @@ fun HomePageCardContent( modifier = modifier, interactionSource = null, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } } 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 4471194e..53417863 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 @@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.preferences.AppPreferences 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.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -52,6 +57,7 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow<HomeState> = _state @@ -78,6 +84,8 @@ class HomeViewModel // Refreshing if a load has already occurred and the rows haven't significantly changed val refresh = state.loadingState == LoadingState.Success && state.settings == settings + Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}") + _state.update { it.copy(settings = settings) } val semaphore = Semaphore(4) @@ -102,6 +110,7 @@ class HomeViewModel userDto = userDto, libraries = libraries, limit = prefs.maxItemsPerRow, + isRefresh = refresh, ) } catch (ex: Exception) { Timber.e(ex, "Error on row %s", row) @@ -186,6 +195,36 @@ class HomeViewModel backdropService.submit(item) } } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.homeRows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + _state.update { + val newRow = + row.items.toMutableList().apply { + removeAt(position.column) + } + it.copy( + homeRows = + it.homeRows.toMutableList().apply { + set(position.row, row.copy(items = newRow)) + }, + ) + } + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } data class HomeState( 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 b5839aad..a7fade4e 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 @@ -165,7 +165,7 @@ class SearchViewModel val results = seerrService .search(query) - .map { DiscoverItem(it) } + .map { seerrService.createDiscoverItem(it) } .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index ff4cfe0a..eaf44d81 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.CollectionType @@ -81,7 +80,7 @@ data class HomeRowPresets( contentScale = PrefContentScale.FIT, ), liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = Cards.HEIGHT_2X3_DP, + genreSize = HomeRowViewOptions.genreDefault.heightDp, ) } @@ -123,7 +122,7 @@ data class HomeRowPresets( ) } - val Thumbnails by lazy { + val SeriesThumbs by lazy { val height = 148 val epHeight = 100 HomeRowPresets( @@ -132,6 +131,7 @@ data class HomeRowPresets( heightDp = epHeight, imageType = ViewOptionImageType.THUMB, aspectRatio = AspectRatio.WIDE, + useSeries = true, episodeImageType = ViewOptionImageType.THUMB, episodeAspectRatio = AspectRatio.WIDE, ), @@ -164,6 +164,50 @@ data class HomeRowPresets( genreSize = epHeight, ) } + + val EpisodeThumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + showTitles = true, + useSeries = false, + episodeImageType = ViewOptionImageType.PRIMARY, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } } } @@ -173,13 +217,13 @@ fun HomeRowPresetsContent( modifier: Modifier = Modifier, ) { val presets = - remember { - listOf( - "Wholphin Default", - "Wholphin Compact", - "Thumbnails", - ) - } + listOf( + stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, + stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, + stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, + stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, + ) + val focusRequesters = remember { List(presets.size) { FocusRequester() } } LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { @@ -192,16 +236,12 @@ fun HomeRowPresetsContent( .fillMaxHeight() .focusRestorer(focusRequesters[0]), ) { - itemsIndexed(presets) { index, title -> + itemsIndexed(presets) { index, (title, preset) -> HomeSettingsListItem( selected = false, headlineText = title, onClick = { - when (index) { - 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) - 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) - 2 -> onApply.invoke(HomeRowPresets.Thumbnails) - } + onApply.invoke(preset) }, modifier = Modifier.focusRequester(focusRequesters[index]), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 1185f719..fb025c02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -240,6 +240,7 @@ fun HomeSettingsPage( onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, + modifier = destModifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 5a0ac965..9eadc615 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -137,7 +137,7 @@ fun HomeSettingsRowList( position = 2 onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[2]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 9110af12..67b57e28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -133,6 +133,7 @@ class HomeSettingsViewModel userDto = userDto, libraries = state.libraries, limit = limit, + isRefresh = false, ) } } 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 0e3d5a43..5f01f20d 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 @@ -55,8 +55,8 @@ 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 +const val TOP_SCRIM_ALPHA = 0.55f +const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height @HiltViewModel class ApplicationContentViewModel 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 eccfd6de..c0e4ce26 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 @@ -121,7 +121,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, @@ -207,7 +209,7 @@ fun DestinationContent( CollectionFolderPhotoAlbum( preferences = preferences, itemId = destination.itemId, - recursive = true, + recursive = false, modifier = modifier, ) } @@ -385,10 +387,19 @@ fun CollectionFolder( } CollectionType.HOMEVIDEOS, + CollectionType.PHOTOS, + -> { + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = recursiveOverride ?: false, + modifier = modifier, + ) + } + CollectionType.MUSICVIDEOS, CollectionType.MUSIC, CollectionType.BOOKS, - CollectionType.PHOTOS, -> { CollectionFolderGeneric( preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt deleted file mode 100644 index 7fc97366..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.Player -import androidx.media3.common.Player.Listener -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn - -/** - * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback - * - * This will clean up the listener when disposed - */ -@Composable -fun AmbientPlayerListener(player: Player) { - val context = LocalContext.current - DisposableEffect(player) { - val listener = - object : Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - context.findActivity()?.keepScreenOn(isPlaying) - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - context.findActivity()?.keepScreenOn(false) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index b3813ed5..e1a291e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -102,7 +102,7 @@ fun DownloadSubtitlesContent( .padding(PaddingValues(24.dp)), ) { Text( - text = "Search & download subtitles", + text = stringResource(R.string.search_and_download_subtitles), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -113,7 +113,7 @@ fun DownloadSubtitlesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Language", + text = stringResource(R.string.language), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -137,7 +137,7 @@ fun DownloadSubtitlesContent( } if (dialogItems.isEmpty()) { Text( - text = "No remote subtitles were found", + text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,20 +1,22 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") val playbackScaleOptions = mapOf( - ContentScale.Fit to "Fit", - ContentScale.None to "None", - ContentScale.Crop to "Crop", + ContentScale.Fit to R.string.content_scale_fit, + ContentScale.None to R.string.none, + ContentScale.Crop to R.string.content_scale_crop, // ContentScale.Inside to "Inside", - ContentScale.FillBounds to "Fill", - ContentScale.FillWidth to "Fill Width", - ContentScale.FillHeight to "Fill Height", + ContentScale.FillBounds to R.string.content_scale_fill, + ContentScale.FillWidth to R.string.content_scale_fill_width, + ContentScale.FillHeight to R.string.content_scale_fill_height, ) val PrefContentScale.scale: ContentScale @@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } + +fun getTypeFor(collectionType: CollectionType): BaseItemKind? = + when (collectionType) { + CollectionType.UNKNOWN -> null + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + CollectionType.MUSIC -> BaseItemKind.AUDIO + CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO + CollectionType.TRAILERS -> BaseItemKind.TRAILER + CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO + CollectionType.BOXSETS -> BaseItemKind.BOX_SET + CollectionType.BOOKS -> BaseItemKind.BOOK + CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM + CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL + CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST + CollectionType.FOLDERS -> BaseItemKind.FOLDER + } 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 75ac0951..8ad9282c 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 @@ -8,7 +8,6 @@ import androidx.annotation.OptIn import androidx.compose.foundation.background 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 @@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable @@ -37,7 +35,6 @@ 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.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -64,6 +61,7 @@ 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.indexOfFirstOrNull import com.github.damontecres.wholphin.ui.seekBack import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.skipStringRes @@ -470,6 +468,14 @@ fun <T> BottomDialog( gravity: Int, currentChoice: BottomDialogItem<T>? = null, ) { + val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + if (currentChoice != null) { + LaunchedEffect(Unit) { + choices.indexOfFirstOrNull { it == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -504,6 +510,7 @@ fun <T> BottomDialog( val interactionSource = remember { MutableInteractionSource() } ListItem( selected = choice == currentChoice, + enabled = choice.enabled, onClick = { onDismissRequest() onSelectChoice(index, choice) @@ -524,6 +531,7 @@ fun <T> BottomDialog( } }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(focusRequesters[index]), ) } } @@ -539,6 +547,7 @@ data class BottomDialogItem<T>( val data: T, val headline: String, val supporting: String?, + val enabled: Boolean = true, ) @PreviewTvSpec 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 04ff620a..a305023c 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 @@ -14,9 +14,12 @@ 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.LaunchedEffect import androidx.compose.runtime.remember 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.LocalView import androidx.compose.ui.res.stringResource @@ -32,6 +35,8 @@ 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.components.SelectedLeadingContent +import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.tryRequestFocus import kotlin.time.Duration enum class PlaybackDialogType { @@ -54,6 +59,7 @@ data class PlaybackSettings( val contentScale: ContentScale, val subtitleDelay: Duration, val hasSubtitleDownloadPermission: Boolean, + val playbackSpeedEnabled: Boolean, ) @Composable @@ -137,6 +143,7 @@ fun PlaybackDialog( data = PlaybackDialogType.PLAYBACK_SPEED, headline = stringResource(R.string.playback_speed), supporting = settings.playbackSpeed.toString(), + enabled = settings.playbackSpeedEnabled, ), ) if (enableVideoScale) { @@ -144,7 +151,9 @@ fun PlaybackDialog( BottomDialogItem( data = PlaybackDialogType.VIDEO_SCALE, headline = stringResource(R.string.video_scale), - supporting = playbackScaleOptions[settings.contentScale], + supporting = + playbackScaleOptions[settings.contentScale] + ?.let { stringResource(it) }, ), ) } @@ -220,7 +229,7 @@ fun PlaybackDialog( playbackScaleOptions.map { (scale, name) -> BottomDialogItem( data = scale, - headline = name, + headline = stringResource(name), supporting = null, ) } @@ -393,6 +402,14 @@ fun StreamChoiceBottomDialog( gravity: Int, currentChoice: Int? = null, ) { + val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } } + if (currentChoice != null) { + LaunchedEffect(Unit) { + choices.indexOfFirstOrNull { it.index == currentChoice }?.let { + focusRequesters.getOrNull(it)?.tryRequestFocus() + } + } + } // TODO enforcing a width ends up ignore the gravity Dialog( onDismissRequest = onDismissRequest, @@ -443,6 +460,7 @@ fun StreamChoiceBottomDialog( if (choice.streamTitle != null) Text(choice.displayTitle) }, interactionSource = interactionSource, + modifier = Modifier.focusRequester(focusRequesters[index]), ) } } 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 f10a0758..5ed82729 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 @@ -20,6 +20,7 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, + private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, private val skipBackOnResume: Duration?, @@ -28,15 +29,22 @@ class PlaybackKeyHandler( private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, ) { + private var leftHandledByRepeat = false + private var rightHandledByRepeat = false + fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - var result = true if (!controlsEnabled) { - result = false + return false + } else if (handleHoldSkip(it)) { + return true } else if (it.type != KeyEventType.KeyUp) { - result = false - } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + return false + } + + var result = true + if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { updateSkipIndicator(-seekBack.inWholeMilliseconds) @@ -111,4 +119,84 @@ class PlaybackKeyHandler( } return result } + + private fun handleHoldSkip(event: KeyEvent): Boolean { + if ( + controllerViewState.controlsVisible || + !skipWithLeftRight || + (!isSkipBack(event) && !isSkipForward(event)) + ) { + return false + } + + val isBack = isSkipBack(event) + return when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + setHandledByRepeat(isBack = isBack, handled = false) + return true + } + val multiplier = + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = normalizedDurationMs(), + ) + setHandledByRepeat(isBack = isBack, handled = true) + seekWithMultiplier(isBack = isBack, multiplier = multiplier) + } else { + setHandledByRepeat(isBack = isBack, handled = false) + } + true + } + + KeyEventType.KeyUp -> { + if (!handledByRepeat(isBack = isBack)) { + seekWithMultiplier(isBack = isBack, multiplier = 1) + } + setHandledByRepeat(isBack = isBack, handled = false) + true + } + + else -> { + false + } + } + } + + private fun seekWithMultiplier( + isBack: Boolean, + multiplier: Int, + ) { + if (isBack) { + val skipDuration = seekBack * multiplier + player.seekBack(skipDuration) + updateSkipIndicator(-skipDuration.inWholeMilliseconds) + } else { + val skipDuration = seekForward * multiplier + player.seekForward(skipDuration) + updateSkipIndicator(skipDuration.inWholeMilliseconds) + } + } + + private fun setHandledByRepeat( + isBack: Boolean, + handled: Boolean, + ) { + if (isBack) { + leftHandledByRepeat = handled + } else { + rightHandledByRepeat = handled + } + } + + private fun handledByRepeat(isBack: Boolean): Boolean = + if (isBack) { + leftHandledByRepeat + } else { + rightHandledByRepeat + } + + private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } 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 2b311e71..0886590e 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 @@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,6 +38,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.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged @@ -254,8 +258,34 @@ fun PlaybackOverlay( exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val chapterIndex = + remember { + val position = playerControls.currentPosition.milliseconds + val index = + chapters + .indexOfFirst { it.position > position } + .minus(1) + .let { + if (it < 0) { + // Didn't find a chapter, so it's either the first or last + if (position < chapters.first().position) { + 0 + } else { + chapters.lastIndex + } + } else { + it + } + }.coerceIn(0, chapters.lastIndex) + index + } + val listState = rememberLazyListState(chapterIndex) val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + focusRequester.tryRequestFocus() + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -276,6 +306,7 @@ fun PlaybackOverlay( style = MaterialTheme.typography.titleLarge, ) LazyRow( + state = listState, contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = @@ -305,10 +336,19 @@ fun PlaybackOverlay( }, interactionSource = interactionSource, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester), + ).ifElse( + index == 0, + Modifier.focusProperties { + // Prevent scrolling left on first card to prevent moving down + left = FocusRequester.Cancel + }, + ), ) } } @@ -448,8 +488,10 @@ fun PlaybackOverlay( style = MaterialTheme.typography.labelLarge, modifier = Modifier - .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), ) } } @@ -570,29 +612,28 @@ fun Controller( fontSize = subtitleTextSize, ) } - if (showClock) { - var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(playerControls) { - while (isActive) { - val remaining = - (playerControls.duration - playerControls.currentPosition) - .div(playerControls.playbackParameters.speed) - .toLong() - .milliseconds - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) - delay(1.seconds) - } + + var endTimeStr by remember { mutableStateOf("...") } + LaunchedEffect(playerControls) { + while (isActive) { + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) + delay(1.seconds) } - Text( - text = "Ends $endTimeStr", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) } + Text( + text = "Ends $endTimeStr", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) } } // TODO need to move these up a level? 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 0313a8c5..8a6b488f 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 @@ -186,7 +186,6 @@ fun PlaybackPageContent( } } - AmbientPlayerListener(player) var contentScale by remember(playerBackend) { mutableStateOf( if (playerBackend == PlayerBackend.MPV) { @@ -236,6 +235,7 @@ fun PlaybackPageContent( skipWithLeftRight = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, @@ -596,6 +596,9 @@ fun PlaybackPageContent( subtitleDelay = subtitleDelay, hasSubtitleDownloadPermission = remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true }, + // TODO Passing through audio prevents changing playback speed + // See https://github.com/damontecres/Wholphin/issues/164 + playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null, ), onDismissRequest = { playbackDialog = null 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 4d44ff80..d3be4324 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 @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory 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.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -140,6 +141,7 @@ class PlaybackViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val imageUrlService: ImageUrlService, + private val screensaverService: ScreensaverService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -189,7 +191,10 @@ class PlaybackViewModel init { viewModelScope.launchIO { - addCloseable { disconnectPlayer() } + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } init() } } @@ -441,11 +446,20 @@ class PlaybackViewModel // Create the correct player for the media createPlayer(videoStream?.hdr == true, videoStream?.is4k == true) - + val subtitleLanguagePreference = + serverRepository.currentUserDto.value + ?.configuration + ?.subtitleLanguagePreference val subtitleStreams = mediaSource.mediaStreams ?.filter { it.type == MediaStreamType.SUBTITLE } - ?.map { + .let { + if (subtitleLanguagePreference.isNotNullOrBlank()) { + it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language } + } else { + it + } + }?.map { SimpleMediaStream.from(context, it, true) }.orEmpty() @@ -1424,6 +1438,10 @@ class PlaybackViewModel } } } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + screensaverService.keepScreenOn(isPlaying) + } } data class PlayerState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt new file mode 100644 index 00000000..6925d5cc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.playback + +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 + +/** + * Shared seek acceleration profile for hold-to-seek behavior. + * Keep this in sync anywhere directional key repeat seeking is handled. + */ +fun calculateSeekAccelerationMultiplier( + repeatCount: Int, + durationMs: Long, +): Int { + if (repeatCount <= 0 || durationMs <= 0L) return 1 + + // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. + val scaledRepeatCount = repeatCount / 3 + if (scaledRepeatCount <= 0) return 1 + + val durationMinutes = durationMs / 60_000L + return when { + durationMinutes < 30 -> { + if (scaledRepeatCount < 30) 1 else 2 + } + + durationMinutes < 90 -> { + when { + scaledRepeatCount < 13 -> 1 + scaledRepeatCount < 50 -> 2 + scaledRepeatCount < 75 -> 3 + else -> 4 + } + } + + durationMinutes < 150 -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 2 + scaledRepeatCount < 60 -> 4 + else -> 6 + } + } + + else -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 3 + scaledRepeatCount < 60 -> 6 + else -> 10 + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index d1db4144..92c6114b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview import kotlin.time.Duration @@ -80,15 +79,16 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) + seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) + seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -126,16 +126,19 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) + seekPositionMs = + (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds * multiplier) + .coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -148,8 +151,9 @@ fun IntervalSeekBarImpl( fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - onLeft: () -> Unit, - onRight: () -> Unit, + durationMs: Long, + onLeft: (Int) -> Unit, + onRight: (Int) -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -158,14 +162,13 @@ fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() + var leftHandledByRepeat by remember { mutableStateOf(false) } + var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { Canvas( modifier = Modifier @@ -173,24 +176,71 @@ fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - val trigger = - event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 when (event.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - if (trigger) onLeft.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + leftHandledByRepeat = true + onLeft.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { + leftHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) + } + leftHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - if (trigger) onRight.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + rightHandledByRepeat = true + onRight.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount, + durationMs = durationMs, + ), + ) + } else { + rightHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } } false - }.handleDPadKeyEvents( - onLeft = onLeft, - onRight = onRight, - ).focusable(enabled = enabled, interactionSource = interactionSource), + }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 9b9bcf9c..1762f916 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import java.io.File -import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -226,7 +225,7 @@ fun <T> ComposablePreference( } is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues).toSortedSet() + val values = stringArrayResource(preference.displayValues) val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -237,12 +236,15 @@ fun <T> ComposablePreference( list } MultiChoicePreference( - possibleValues = values as SortedSet<Any>, + possibleValues = preference.allValues, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(selectedValues.toList() as T) + onValueChange.invoke(it.toList() as T) + }, + valueDisplay = { index, _ -> + Text(values[index]) }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index a4686b10..bb34207f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun <T> MultiChoicePreference( title: String, summary: String?, - possibleValues: Set<T>, + possibleValues: List<T>, selectedValues: Set<T>, onValueChange: (List<T>) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -59,7 +59,7 @@ fun <T> MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(item) }, + headlineContent = { valueDisplay.invoke(index, item) }, trailingContent = { Switch( checked = selectedValues.contains(item), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index c9f6f529..454a0266 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -35,6 +35,7 @@ enum class PreferenceScreenOption { ADVANCED, EXO_PLAYER, MPV, + SCREENSAVER, ; companion object { 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 47d7fbe2..256077c1 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 @@ -50,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -128,6 +130,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences + PreferenceScreenOption.SCREENSAVER -> screensaverPreferences } val screenTitle = when (preferenceScreenOption) { @@ -135,6 +138,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options + PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings } var visible by remember { mutableStateOf(false) } @@ -149,7 +153,7 @@ fun PreferencesContent( try { System.loadLibrary("mpv") System.loadLibrary("player") - } catch (ex: Exception) { + } catch (ex: UnsatisfiedLinkError) { Timber.w(ex, "Could not load libmpv") showToast(context, "MPV is not supported on this device") viewModel.preferenceDataStore.updateData { @@ -283,7 +287,9 @@ fun PreferencesContent( if (movementSounds) playOnClickSound(context) if (release != null && updateAvailable) { release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) + viewModel.navigationManager.navigateTo( + Destination.UpdateApp, + ) } } else { updateVM.init(preferences.updateUrl) @@ -313,7 +319,8 @@ fun PreferencesContent( val summary = remember(cacheUsage) { cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val diskMB = + it.imageDiskUsed / AppPreference.MEGA_BIT val memoryUsedMB = it.imageMemoryUsed / AppPreference.MEGA_BIT val memoryMaxMB = @@ -392,7 +399,10 @@ fun PreferencesContent( seerrDialogMode = when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error(conn.serverUrl, conn.ex) + SeerrDialogMode.Error( + conn.server.url, + conn.ex, + ) } SeerrConnectionStatus.NotConfigured -> { @@ -409,9 +419,19 @@ fun PreferencesContent( modifier = Modifier, summary = when (seerrConnection) { - is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) - SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) - is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + is SeerrConnectionStatus.Error -> { + stringResource(R.string.voice_error_server) + } + + SeerrConnectionStatus.NotConfigured -> { + stringResource( + R.string.add_server, + ) + } + + is SeerrConnectionStatus.Success -> { + stringResource(R.string.enabled) + } }, onLongClick = {}, interactionSource = interactionSource, @@ -434,6 +454,19 @@ fun PreferencesContent( ) } + ScreensaverPreference.Start -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.screensaverService.start() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -521,6 +554,7 @@ fun PreferencesContent( currentUsername = currentUser?.name, status = status, onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } @@ -597,6 +631,7 @@ fun PreferencesPage( PreferenceScreenOption.ADVANCED, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, + PreferenceScreenOption.SCREENSAVER, -> { PreferencesContent( initialPreferences, 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 bec82e58..58f9a5e3 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 @@ -11,6 +11,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.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO @@ -35,6 +36,7 @@ class PreferencesViewModel val preferenceDataStore: DataStore<AppPreferences>, val navigationManager: NavigationManager, val backdropService: BackdropService, + val screensaverService: ScreensaverService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index b209ee59..5fd47621 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -25,6 +25,7 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -382,7 +383,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 4902ca06..a4af8580 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -352,6 +353,7 @@ fun SwitchServerContent( ), modifier = Modifier + .testTag("server_url_text") .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3..08398a86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -42,6 +45,8 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) 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 index d2b48c35..5fa8c667 100644 --- 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 @@ -4,7 +4,6 @@ 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.size import androidx.compose.foundation.layout.width @@ -62,19 +61,23 @@ fun AddSeerrServerApiKey( val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( - text = "Enter URL & API Key", + text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) + val labelWidth = 90.dp Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.url), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = url, @@ -104,8 +107,11 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", - modifier = Modifier.padding(end = 8.dp), + text = stringResource(R.string.api_key), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = apiKey, @@ -173,7 +179,7 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) val labelWidth = 90.dp Row( 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 index 3601fdd7..d84b98db 100644 --- 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 @@ -1,11 +1,17 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import androidx.compose.foundation.layout.widthIn 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.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.DialogItem @@ -18,6 +24,7 @@ fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onResetStatus: () -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } @@ -32,6 +39,7 @@ fun AddSeerServerDialog( -> { BasicDialog( onDismissRequest = { authMethod = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( onSubmit = { url, username, password -> @@ -39,6 +47,7 @@ fun AddSeerServerDialog( }, username = currentUsername ?: "", status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -52,6 +61,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -59,7 +69,10 @@ fun AddSeerServerDialog( null -> { ChooseSeerrLoginType( onDismissRequest = onDismissRequest, - onChoose = { authMethod = it }, + onChoose = { + onResetStatus.invoke() + authMethod = it + }, ) } } @@ -71,27 +84,26 @@ fun ChooseSeerrLoginType( 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) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, ), - ) - } + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = stringResource(R.string.seerr_local_user), + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, 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 index 4f93217a..7c05194a 100644 --- 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 @@ -50,63 +50,74 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } return@launchIO } - var result: LoadingState = LoadingState.Error("No url") + Timber.v("Urls to try: %s", urls) + val results = mutableMapOf<HttpUrl, LoadingState>() for (url in urls) { Timber.d("Trying %s", url) - result = - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in") - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - result = LoadingState.Error("Invalid credentials", ex) - break - } else { - LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception logging in") - LoadingState.Error(ex) - } - if (result is LoadingState.Success) { - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + results[url] = LoadingState.Success break + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in %s", url) + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + results[url] = LoadingState.Error("Invalid credentials", ex) + } else { + results[url] = LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "ClientException logging in %s", url) + results[url] = LoadingState.Error(ex) } } - if (result is LoadingState.Error) { - showToast(context, "Error: ${result.message}") + val result = results.filter { (url, state) -> state is LoadingState.Success } + if (result.isNotEmpty()) { + val url = result.keys.first() + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + serverConnectionStatus.update { LoadingState.Success } + } else { + val message = + results + .map { (url, state) -> + val s = state as? LoadingState.Error + "$url - ${s?.localizedMessage}" + }.joinToString("\n") + showToast(context, "Could not connect") + serverConnectionStatus.update { LoadingState.Error(message) } } - serverConnectionStatus.update { result } } } fun removeServer() { viewModelScope.launchIO { - seerrServerRepository.removeServer() + val result = seerrServerRepository.removeServerForCurrentUser() + if (!result) { + showToast(context, "Could not remove server") + } } } @@ -116,37 +127,50 @@ class SwitchSeerrViewModel } fun createUrls(url: String): List<HttpUrl> { - val urls = mutableListOf<String>() + val urls = mutableListOf<HttpUrl>() if (url.startsWith("http://") || url.startsWith("https://")) { - urls.add(url) val httpUrl = url.toHttpUrl() + urls.add(httpUrl) if (HttpUrl.defaultPort(httpUrl.scheme) == httpUrl.port) { - urls.add("$url:5055") + urls.add(httpUrl.newBuilder().port(5055).build()) } } else { - urls.add("http://$url") val httpUrl = "http://$url".toHttpUrl() + urls.add(httpUrl) if (httpUrl.port == 80) { - urls.add("https://$url") - urls.add("http://$url:5055") - urls.add("https://$url:5055") + urls.add(httpUrl.newBuilder().scheme("https").build()) + urls.add(httpUrl.newBuilder().port(5055).build()) + urls.add( + httpUrl + .newBuilder() + .scheme("https") + .port(5055) + .build(), + ) } else { - urls.add("https://$url") + urls.add(httpUrl.newBuilder().scheme("https").build()) } } - return urls.map { cleanUrl(it).toHttpUrl() } + return urls } -private fun cleanUrl(url: String) = - if (!url.endsWith("/api/v1")) { +fun createSeerrApiUrl(url: String): String = + if (url.isBlank()) { + url + } else if (url.endsWith("/api/v1") || url.endsWith("/api/v1/")) { + url + } else { url .toHttpUrl() .newBuilder() - .apply { - addPathSegment("api") - addPathSegment("v1") - }.build() + .addPathSegment("api") + .addPathSegment("v1") + .build() .toString() - } else { - url } + +fun migrateSeerrUrl(url: String): String { + var url = url.removeSuffix("/api/v1/").removeSuffix("/api/v1") + if (!url.endsWith("/")) url += "/" + return url +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt index 1c121ef6..2b3ef667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -8,13 +8,11 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,17 +52,18 @@ import androidx.media3.ui.compose.modifiers.resizeWithContentScale import androidx.media3.ui.compose.state.rememberPresentationState import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi import coil3.compose.SubcomposeAsyncImage +import coil3.compose.useExistingImageAsPlaceholder import coil3.request.ImageRequest -import coil3.request.crossfade +import coil3.request.transitionFactory import coil3.size.Size import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDpad @@ -73,12 +72,14 @@ import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.MediaType import timber.log.Timber import kotlin.math.abs +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "ImagePage" private const val DEBUG = false @SuppressLint("ConfigurationScreenWidthHeight") @OptIn(UnstableApi::class) +@kotlin.OptIn(ExperimentalCoilApi::class) @Composable fun SlideshowPage( slideshow: Destination.Slideshow, @@ -96,7 +97,7 @@ fun SlideshowPage( val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) val position by viewModel.position.observeAsState(0) val pager by viewModel.pager.observeAsState() - val imageState by viewModel.image.observeAsState() +// val imageState by viewModel.image.observeAsState() var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } val isZoomed = zoomFactor * 100 > 102 @@ -200,34 +201,15 @@ fun SlideshowPage( } } - LaunchedEffect(imageState) { - reset(true) - } val player = viewModel.player val presentationState = rememberPresentationState(player) LaunchedEffect(slideshowActive) { player.repeatMode = if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE - context.findActivity()?.keepScreenOn(slideshowActive) - } - DisposableEffect(Unit) { - onDispose { - context.findActivity()?.keepScreenOn(false) - } } var longPressing by remember { mutableStateOf(false) } - val contentModifier = - Modifier - .clickable( - interactionSource = null, - indication = null, - onClick = { - showOverlay = !showOverlay - }, - ) - Box( modifier = modifier @@ -312,149 +294,145 @@ fun SlideshowPage( result }, ) { - when (loadingState) { + when (val st = loadingState) { ImageLoadingState.Error -> { ErrorMessage("Error loading image", null, modifier) } ImageLoadingState.Loading -> { - LoadingPage(modifier) + LoadingPage(modifier, false) } is ImageLoadingState.Success -> { - imageState?.let { imageState -> - if (imageState.image.data.mediaType == MediaType.VIDEO) { - LaunchedEffect(imageState.id) { - val mediaItem = - MediaItem - .Builder() - .setUri(imageState.url) - .build() - player.setMediaItem(mediaItem) - player.repeatMode = - if (slideshowState.enabled) { - Player.REPEAT_MODE_OFF - } else { - Player.REPEAT_MODE_ONE - } - player.prepare() - player.play() - viewModel.pulseSlideshow(Long.MAX_VALUE) - } - LifecycleStartEffect(Unit) { - onStopOrDispose { - player.stop() + val imageState = st.image + LaunchedEffect(imageState) { + reset(true) + } + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() } - val contentScale = ContentScale.Fit - val scaledModifier = - contentModifier.resizeWithContentScale( - contentScale, - presentationState.videoSizeDp, - ) - PlayerSurface( - player = player, - surfaceType = SURFACE_TYPE_SURFACE_VIEW, - modifier = - scaledModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - }.rotate(rotateAnimation), + } + val contentScale = ContentScale.Fit + val scaledModifier = + Modifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, ) - if (presentationState.coverSurface) { - Box( - Modifier - .matchParentSize() - .background(Color.Black), - ) - } - } else { - val colorFilter = - remember(imageState.id, imageFilter) { - if (imageFilter.hasImageFilter()) { - ColorMatrixColorFilter(imageFilter.colorMatrix) - } else { - null - } - } - // If the image loading is large, show the thumbnail while waiting - // TODO - val showLoadingThumbnail = true - SubcomposeAsyncImage( - modifier = - contentModifier - .fillMaxSize() - .graphicsLayer { - scaleX = zoomAnimation - scaleY = zoomAnimation - translationX = panXAnimation - translationY = panYAnimation - - val xTransform = - (screenWidth - panXAnimation) / (screenWidth * 2) - val yTransform = - (screenHeight - panYAnimation) / (screenHeight * 2) - if (DEBUG) { - Timber.d( - "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", - ) - } - - transformOrigin = TransformOrigin(xTransform, yTransform) - }.rotate(rotateAnimation), - model = - ImageRequest - .Builder(LocalContext.current) - .data(imageState.url) - .size(Size.ORIGINAL) - .crossfade(!showLoadingThumbnail) - .build(), - contentDescription = null, - contentScale = ContentScale.Fit, - colorFilter = colorFilter, - error = { - Text( - modifier = - Modifier - .align(Alignment.Center), - text = "Error loading image", - color = MaterialTheme.colorScheme.onBackground, - ) - }, - loading = { - ImageLoadingPlaceholder( - thumbnailUrl = imageState.thumbnailUrl, - showThumbnail = showLoadingThumbnail, - colorFilter = colorFilter, - modifier = Modifier.fillMaxSize(), - ) - }, - // Ensure that if an image takes a long time to load, it won't be skipped - onLoading = { - viewModel.pulseSlideshow(Long.MAX_VALUE) - }, - onSuccess = { - viewModel.pulseSlideshow() - }, - onError = { - Timber.e( - it.result.throwable, - "Error loading image ${imageState.id}", - ) - Toast - .makeText( - context, - "Error loading image: ${it.result.throwable.localizedMessage}", - Toast.LENGTH_LONG, - ).show() - viewModel.pulseSlideshow() - }, + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), ) } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .apply { + if (isZoomed) size(Size.ORIGINAL) + }.transitionFactory(CrossFadeFactory(750.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) } } } @@ -464,30 +442,37 @@ fun SlideshowPage( exit = slideOutVertically { it }, modifier = Modifier.align(Alignment.BottomStart), ) { - imageState?.let { imageState -> - ImageOverlay( - modifier = - contentModifier - .fillMaxWidth() - .background(AppColors.TransparentBlack50), - onDismiss = { showOverlay = false }, - player = player, - slideshowControls = slideshowControls, - slideshowEnabled = slideshowState.enabled, - image = imageState, - position = position, - count = pager?.size ?: -1, - onClickItem = {}, - onLongClickItem = {}, - onZoom = ::zoom, - onRotate = { rotation += it }, - onReset = { reset(true) }, - onShowFilterDialogClick = { - showFilterDialog = true - showOverlay = false - viewModel.pauseSlideshow() - }, - ) + when (val st = loadingState) { + ImageLoadingState.Error -> {} + + ImageLoadingState.Loading -> {} + + is ImageLoadingState.Success -> { + val imageState = st.image + ImageOverlay( + modifier = + Modifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } } } AnimatedVisibility(showFilterDialog) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt index 4d2d0662..3a4f3bce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.model.VideoFilter import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -67,6 +68,7 @@ class SlideshowViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val userPreferencesService: UserPreferencesService, + private val screensaverService: ScreensaverService, @Assisted val slideshowSettings: Destination.Slideshow, ) : ViewModel(), Player.Listener { @@ -110,6 +112,7 @@ class SlideshowViewModel init { addCloseable { + screensaverService.keepScreenOn(false) player.removeListener(this@SlideshowViewModel) player.release() } @@ -138,7 +141,7 @@ class SlideshowViewModel parentId = slideshowSettings.parentId, includeItemTypes = includeItemTypes, fields = PhotoItemFields, - recursive = true, + recursive = slideshowSettings.recursive, sortBy = listOf(slideshowSettings.sortAndDirection.sort), sortOrder = listOf(slideshowSettings.sortAndDirection.direction), ), @@ -190,7 +193,8 @@ class SlideshowViewModel _pager.value?.let { pager -> viewModelScope.launchIO { try { - val image = pager.getBlocking(position) + val image = + if (position in pager.indices) pager.getBlocking(position) else null Timber.v("Got image for $position: ${image != null}") if (image != null) { this@SlideshowViewModel.position.setValueOnMain(position) @@ -282,6 +286,7 @@ class SlideshowViewModel private var slideshowJob: Job? = null fun startSlideshow() { + screensaverService.keepScreenOn(true) _slideshow.update { SlideshowState(enabled = true, paused = false) } @@ -295,6 +300,7 @@ class SlideshowViewModel } fun stopSlideshow() { + screensaverService.keepScreenOn(false) slideshowJob?.cancel() _slideshow.update { SlideshowState(enabled = false, paused = false) 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 d07f86ff..bca5ae64 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 @@ -34,7 +34,7 @@ data class Clock( fun ProvideLocalClock(content: @Composable () -> Unit) { val clock = remember { Clock() } LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { + withContext(Dispatchers.Default) { while (isActive) { val now = LocalDateTime.now() val time = TimeFormatter.format(now) 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 index ff4e62da..15d35b3b 100644 --- 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 @@ -27,18 +27,18 @@ object StreamFormatting { 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" + width > 5120 || height > 4320 -> "8K" + width > 2560 || height > 1440 -> "4K" + width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) + width > 1280 || height > 962 -> "1080" + interlaced(interlaced) + width > 1024 || height > 576 -> "720" + interlaced(interlaced) + width > 960 || height > 544 -> "576" + interlaced(interlaced) + width > 845 || height > 480 -> "540" + interlaced(interlaced) + width > 720 || height > 404 -> "480" + interlaced(interlaced) + width > 682 || height > 384 -> "404" + interlaced(interlaced) + width > 640 || height > 360 -> "384" + interlaced(interlaced) + width > 426 || height > 240 -> "360" + interlaced(interlaced) + width > 256 || height > 144 -> "240" + interlaced(interlaced) else -> height.toString() + interlaced(interlaced) } } @@ -92,12 +92,13 @@ object StreamFormatting { 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) + profile?.contains("DTS", true) == true -> { + when { + profile.contains("X", true) -> context.getString(R.string.dts_x) + profile.contains("MA", true) -> context.getString(R.string.dts_hd_ma) + profile.contains("HD", true) -> context.getString(R.string.dts_hd) + else -> context.getString(R.string.dts) + } } else -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index b03ce9f9..08f9318e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -146,17 +146,17 @@ class ApiRequestPager<T>( position: Int, itemId: UUID, ) { - val item = - api.userLibraryApi.getItem(itemId).content.let { - BaseItem.from( - it, - api, - useSeriesForPrimary, - ) - } - val pageNumber = position / pageSize - val index = position - pageNumber * pageSize mutex.withLock { + val item = + api.userLibraryApi.getItem(itemId).content.let { + BaseItem.from( + it, + api, + useSeriesForPrimary, + ) + } + val pageNumber = position / pageSize + val index = position - pageNumber * pageSize val page = cachedPages.getIfPresent(pageNumber) if (page != null && index in page.indices) { page[index] = item @@ -166,6 +166,22 @@ class ApiRequestPager<T>( } } + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) + } + } + } + fetchPageBlocking(position, true) + } + companion object { private const val DEBUG = false } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index da246f73..fa0476cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -9,6 +9,7 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -47,6 +48,7 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt new file mode 100644 index 00000000..3ea6bdf9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.ApiClientFactory +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.HttpMethod +import org.jellyfin.sdk.api.client.RawResponse +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.api.sockets.SocketApi +import org.jellyfin.sdk.api.sockets.SocketConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import kotlin.coroutines.CoroutineContext + +/** + * Wraps [ApiClient.request] with the given [CoroutineContext] + */ +class CoroutineContextApiClient( + private val client: ApiClient, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClient() { + override val baseUrl: String? + get() = client.baseUrl + override val accessToken: String? + get() = client.accessToken + override val clientInfo: ClientInfo + get() = client.clientInfo + override val deviceInfo: DeviceInfo + get() = client.deviceInfo + override val httpClientOptions: HttpClientOptions + get() = client.httpClientOptions + override val webSocket: SocketApi + get() = client.webSocket + + override fun update( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) { + client.update(baseUrl, accessToken, clientInfo, deviceInfo) + } + + override suspend fun request( + method: HttpMethod, + pathTemplate: String, + pathParameters: Map<String, Any?>, + queryParameters: Map<String, Any?>, + requestBody: Any?, + ): RawResponse = + withContext(coroutineContext) { + client.request( + method, + pathTemplate, + pathParameters, + queryParameters, + requestBody, + ) + } +} + +class CoroutineContextApiClientFactory( + private val factory: OkHttpFactory, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClientFactory, + SocketConnectionFactory { + override fun create( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, + ): ApiClient = + CoroutineContextApiClient( + factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), + coroutineContext, + ) + + override fun create( + clientOptions: HttpClientOptions, + scope: CoroutineScope, + ): SocketConnection = factory.create(clientOptions, scope) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 43d3e45a..c1630900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import android.content.Context import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -49,7 +50,7 @@ class CrashReportSender : ReportSender { createJellyfin { this.context = context clientInfo = AppModule.clientInfo(context) - deviceInfo = AppModule.deviceInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory minimumServerVersion = Jellyfin.minimumVersion diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp index b092b661..26c69300 100644 --- a/app/src/main/jni/event.cpp +++ b/app/src/main/jni/event.cpp @@ -107,6 +107,7 @@ void *event_thread(void *arg) case MPV_EVENT_END_FILE: mp_end_file = (mpv_event_end_file*)mp_event->data; sendEndFileEventToJava(env, mp_end_file); + break; default: ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); sendEventToJava(env, mp_event->event_id); diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 134b2341..c65a1463 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -145,6 +145,16 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +message ScreensaverPreferences{ + int64 start_delay = 1; + int64 duration = 2; + bool animate = 3; + int32 max_age_filter = 4; + bool enabled = 5; + repeated string item_types = 6; + bool show_clock = 7; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -156,6 +166,8 @@ message InterfacePreferences { LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; + ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; } message AdvancedPreferences { diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000..327f969b --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="about">حول</string> + <string name="active_recordings">التسجيلات النشطة</string> + <string name="add_favorite">إضافة للمفضلة</string> + <string name="add_server">إضافة خادم</string> + <string name="add_user">إضافة مستخدم</string> + <string name="audio">الصوت</string> + <string name="birthplace">مكان الميلاد</string> + <string name="bitrate">معدل البت</string> + <string name="born">وُلد</string> + <string name="cancel_recording">إلغاء التسجيل</string> + <string name="cancel_series_recording">إلغاء تسجيل المسلسل</string> + <string name="cancel">إلغاء</string> + <string name="chapters">الفصول</string> + <string name="choose_stream">اختيار %1$s</string> + <string name="clear_image_cache">مسح ذاكرة التخزين المؤقت للصور</string> + <string name="collection">مجموعة</string> + <string name="collections">المجموعات</string> + <string name="commercial">تجاري</string> + <string name="community_rating">تقييم المجتمع</string> + <string name="compose_error_message_title">حدث خطأ! اضغط على الزر لإرسال السجلات إلى خادمك.</string> + <string name="confirm">تأكيد</string> + <string name="continue_watching">تابع المشاهدة</string> + <string name="critic_rating">تقييم النقاد</string> + <string name="decimal_seconds">%.2f ثانية</string> + <string name="default_track">الافتراضي</string> + <string name="delete">حذف</string> + <string name="died">تُوفي</string> + <string name="directed_by">إخراج %1$s</string> + <string name="director">المخرج</string> + <string name="disabled">مُعطّل</string> + <string name="discovered_servers">الخوادم المكتشفة</string> + <string name="download_and_update"><![CDATA[تنزيل وتحديث]]></string> + <string name="downloading">تنزيل…</string> + <string name="enabled">مُفعّل</string> + <string name="ends_at">ينتهي عند %1$s</string> + <string name="enter_server_url">أدخل عنوان IP للخادم أو رابط URL</string> + <string name="enter_server_address">أدخل عنوان الخادم</string> + <string name="episodes">الحلقات</string> + <string name="error_loading_collection">خطأ في تحميل المجموعة %1$s</string> + <string name="external_track">خارجي</string> + <string name="favorites">المفضلة</string> + <string name="file_size">الحجم</string> + <string name="forced_track">إجبارية</string> + <string name="genres">الأنواع</string> + <string name="go_to_series">انتقل إلى المسلسل</string> + <string name="go_to">انتقل إلى</string> + <string name="hide_controller_timeout">إخفاء عناصر التحكم في التشغيل</string> + <string name="hide_debug_info">إخفاء معلومات تصحيح الأخطاء</string> + <string name="hide">إخفاء</string> + <string name="home">الرئيسية</string> + <string name="immediate">فوري</string> + <string name="intro">المقدمة</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">المكتبة</string> + <string name="license_info">معلومات الترخيص</string> + <string name="live_tv">تلفزيون مباشر</string> + <string name="loading">تحميل…</string> + <string name="mark_entire_series_as_played">هل تريد تعيين المسلسل بأكمله كمُشاهَد؟</string> + <string name="mark_entire_series_as_unplayed">هل تريد تعيين المسلسل بأكمله كغير مشاهد؟</string> + <string name="mark_unwatched">تعيين كغير مشاهد</string> + <string name="mark_watched">تعيين كمشاهد</string> + <string name="max_bitrate">الحد الأقصى لمعدل البت</string> + <string name="more_like_this">المزيد من هذا القبيل</string> + <string name="more">المزيد</string> + <plurals name="movies"> + <item quantity="zero">الأفلام</item> + <item quantity="one"></item> + <item quantity="two"></item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="name">الاسم</string> + <string name="next_up">التالي</string> + <string name="no_data">لا توجد بيانات</string> + <string name="no_results">لا توجد نتائج</string> + <string name="no_servers_found">لم يتم العثور على خوادم</string> + <string name="no_scheduled_recordings">لا توجد تسجيلات مجدولة</string> + <string name="no_update_available">لا يوجد تحديث متاح</string> + <string name="none">لا شيء</string> + <string name="only_forced_subtitles">الترجمات الإجبارية فقط</string> + <string name="outro">خاتمة</string> + <string name="path">المسار</string> + <plurals name="people"> + <item quantity="zero">الأشخاص</item> + <item quantity="one"></item> + <item quantity="two"></item> + <item quantity="few"></item> + <item quantity="many"></item> + <item quantity="other"></item> + </plurals> + <string name="play_count">عدد مرات التشغيل</string> + <string name="play_from_here">تشغيل من هنا</string> + <string name="play">تشغيل</string> + <string name="playback_debug_info">إظهار معلومات التشغيل التفصيلية</string> + <string name="playback_speed">سرعة التشغيل</string> + <string name="playback">التشغيل</string> + <string name="playlist">قائمة التشغيل</string> + <string name="playlists">قوائم التشغيل</string> + <string name="preview">معاينة</string> + <string name="profile_specific_settings">إعدادات ملف تعريف المستخدم</string> + <string name="queue">قائمة الانتظار</string> + <string name="recap">ملخص ما سبق</string> + <string name="recently_added_in">أُضيف مؤخرًا في %1$s</string> + <string name="recently_added">أُضيف مؤخرًا</string> + <string name="recently_recorded">التسجيلات الأخيرة</string> + <string name="recently_released">أحدث الإصدارات</string> + <string name="recommended">مقترح</string> + <string name="record_program">تسجيل البرنامج</string> + <string name="record_series">تسجيل المسلسل</string> + <string name="remove_favorite">إزالة من المفضلة</string> + <string name="restart">إعادة تشغيل</string> + <string name="resume">استئناف</string> + <string name="save">حفظ</string> + <string name="search_and_download"><![CDATA[بحث وتنزيل]]></string> + <string name="search">بحث</string> + <string name="searching">جارٍ البحث…</string> + <string name="voice_search">البحث الصوتي</string> + <string name="voice_starting">جارٍ البدء</string> + <string name="voice_search_prompt">تحدث للبحث</string> + <string name="processing">جارٍ المعالجة</string> + <string name="press_back_to_cancel">اضغط رجوع للإلغاء</string> + <string name="voice_error_audio">خطأ في تسجيل الصوت</string> + <string name="voice_error_client">خطأ في التعرف على الصوت</string> + <string name="voice_error_permissions">مطلوب إذن الوصول للميكروفون</string> + <string name="voice_error_network">خطأ في الشبكة</string> + <string name="voice_error_network_timeout">انتهت مهلة الشبكة</string> + <string name="voice_error_no_match">لم يتم التعرف على الكلام</string> +</resources> diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0e3d0be..e41bd2b5 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -401,6 +401,7 @@ <string name="clear_track_choices">Vyčistit výběr skladeb</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">True HD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -495,8 +496,8 @@ <string name="apply_all_rows">Použít na všechny řádky</string> <string name="customize_home">Přizpůsobit domovskou stránku</string> <string name="home_rows">Řádky na domovské stránce</string> - <string name="load_from_server">Načíst ze serveru</string> - <string name="save_to_server">Uložit na server</string> + <string name="load_from_server">Načíst ze serverového uživatelského profilu</string> + <string name="save_to_server">Uložit do serverového uživatelského profilu</string> <string name="load_from_web_client">Načíst z webového klienta</string> <string name="use_series">Použít obrázek seriálu</string> <string name="add_row_for">Přidat řádek pro %1$s</string> @@ -511,4 +512,59 @@ <string name="display_presets">Zobrazit předvolby</string> <string name="display_presets_description">Vestavěné předvolby pro rychlou úpravu všech řádků</string> <string name="customize_home_summary">Vyberte řádky a obrázky na domovské stránce</string> + <string name="favorite_items">Oblíbené %s</string> + <string name="content_scale_fit">Přizpůsobit</string> + <string name="content_scale_crop">Oříznout</string> + <string name="content_scale_fill">Vyplnit</string> + <string name="content_scale_fill_width">Vyplnit šířku</string> + <string name="content_scale_fill_height">Vyplnit výšku</string> + <string name="display_preset_default">Výchozí předvolba Wholphin</string> + <string name="display_preset_compact">Wholphin kompaktní</string> + <string name="display_preset_series_thumb">Obrázky náhledů seriálů</string> + <string name="display_preset_episode_thumbnails">Obrázky náhledů epizod</string> + <string name="volume_lowest">Nejnižší</string> + <string name="volume_low">Nízká</string> + <string name="volume_medium">Střední</string> + <string name="volume_high">Vysoká</string> + <string name="volume_full">Plná</string> + <string name="purple">Fialová</string> + <string name="orange">Oranžová</string> + <string name="bold_blue">Odvážná modrá</string> + <string name="black">Černá</string> + <string name="skip_ignore">Ignorovat</string> + <string name="skip_automatically">Automaticky přeskočit</string> + <string name="skip_ask">Zeptat se</string> + <string name="ffmpeg_fallback">Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér</string> + <string name="ffmpeg_prefer">Preferovat FFmpeg nad vestavěnými dekodéry</string> + <string name="ffmpeg_never">Nikdy nepoužívat dekodéry FFmpeg</string> + <string name="next_up_playback_end">Na konci přehrávání</string> + <string name="next_up_outro">Během titulků</string> + <string name="white">Bílá</string> + <string name="light_gray">Světle šedá</string> + <string name="dark_gray">Tmavě šedá</string> + <string name="yellow">Žlutá</string> + <string name="cyan">Azurová</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Okrajová linka</string> + <string name="subtitle_edge_shadow">Stín</string> + <string name="background_style_wrap">Zabalené</string> + <string name="background_style_boxed">Krabicové</string> + <string name="prefer_mpv">Preferovat MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Použít ExoPlayer pro přehrávání HDR obsahu</string> + <string name="aspect_ratios_poster">Plakát (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Čtverec (1:1)</string> + <string name="image_type_primary">Primární</string> + <string name="image_type_thumb">Náhled</string> + <string name="backdrop_style_dynamic">Obrázek s dynamickou barvou</string> + <string name="backdrop_style_image">Pouze obrázek</string> + <string name="enter_url_api_key"><![CDATA[Zadejte adresu a klíč API]]></string> + <string name="api_key">Klíč API</string> + <string name="seerr_login">Přihlásit se k serveru Seerr</string> + <string name="seerr_jellyfin_user">Uživatel Jellyfin</string> + <string name="seerr_local_user">Lokální uživatel</string> + <string name="search_and_download_subtitles"><![CDATA[Hledat a stáhnout titulky]]></string> + <string name="no_subtitles_found">Nenalezeny žádné vzdálené titulky</string> + <string name="series_continueing">Současnost</string> </resources> diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 43da96b0..7fdb5d82 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -76,7 +76,7 @@ <string name="no_update_available">Ingen opdatering tilgængelig</string> <string name="none">Ingen</string> <string name="only_forced_subtitles">Kun tvungne undertekster</string> - <string name="outro">Outro</string> + <string name="outro">Eftertekster</string> <string name="path">Filsti</string> <plurals name="people"> <item quantity="one">Personer</item> @@ -151,7 +151,7 @@ <string name="trailer">Trailer</string> <plurals name="trailers"> <item quantity="one">Trailere</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <string name="tv_dvr_schedule">DVR-plan</string> <string name="tv_guide">TV-guide</string> @@ -159,7 +159,7 @@ <string name="tv_seasons">Sæsoner</string> <plurals name="tv_shows"> <item quantity="one">TV-serier</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <string name="ui_interface">Grænseflade</string> <string name="unknown">Ukendt</string> @@ -187,47 +187,47 @@ <string name="extras">Ekstra</string> <plurals name="other_extras"> <item quantity="one">Andet</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Bag kulisserne</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="theme_songs"> <item quantity="one">Temasange</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="theme_videos"> <item quantity="one">Temavideoer</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="clips"> <item quantity="one">Klip</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="deleted_scenes"> <item quantity="one">Slettede scener</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="interviews"> <item quantity="one">Interview</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="scenes"> <item quantity="one">Scener</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="samples"> <item quantity="one">Prøver</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="featurettes"> <item quantity="one">Specialindslag</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="shorts"> <item quantity="one">Kortfilm</item> - <item quantity="other"></item> + <item quantity="other"/> </plurals> <plurals name="downloads"> <item quantity="one">%s download</item> @@ -287,7 +287,7 @@ <string name="skip_commercials_behavior">Adfærd ved at springe reklamer over</string> <string name="skip_forward_preference">Spring fremad</string> <string name="skip_intro_behavior">Adfærd ved at springe intro over</string> - <string name="skip_outro_behavior">Adfærd ved at springe outro over</string> + <string name="skip_outro_behavior">Adfærd ved at springe eftertekster over</string> <string name="skip_previews_behavior">Adfærd ved at springe forhåndsvisninger over</string> <string name="skip_recap_behavior">Adfærd ved at springe opsummeringer over</string> <string name="update_available">Opdatering tilgængelig</string> @@ -304,4 +304,172 @@ <string name="subtitle_style">Undertekststil</string> <string name="background_color">Baggrundsfarve</string> <string name="reset">Nulstil</string> + <string name="player_backend">Afspilning Backend</string> + <string name="mpv_hardware_decoding">MPV: Brug hardwareafkodning</string> + <string name="disable_if_crash">Deaktiver, hvis du oplever fejl</string> + <string name="mpv_options">MPV indstillinger</string> + <string name="exoplayer_options">ExoPlayer indstillinger</string> + <string name="skip_segment_unknown">Spring over segmentet</string> + <string name="skip_segment_commercial">Spring over reklamer</string> + <string name="skip_segment_preview">Spring over forhåndsvisning</string> + <string name="skip_segment_recap">Spring over opsummering</string> + <string name="skip_segment_outro">Spring over eftertekster</string> + <string name="skip_segment_intro">Spring over intro</string> + <string name="played">Afspillet</string> + <string name="filter">Filter</string> + <string name="year">År</string> + <string name="decade">Årti</string> + <string name="remove">Fjern</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Brug gpu-next</string> + <string name="mpv_conf">Rediger mpv.conf</string> + <string name="discard_change">Kassér ændringer?</string> + <string name="subtitle_margin">Margin</string> + <string name="verbose_logging">Detaljeret logning</string> + <string name="enter_pin">Indtast PIN</string> + <string name="sign_in_auto">Log ind automatisk</string> + <string name="use_server_credentials">Log ind via server</string> + <string name="press_enter_to_confirm">Tryk på midten for at bekræfte</string> + <string name="require_pin_code">Kræv PIN-kode til profil</string> + <string name="confirm_pin">Bekræft PIN</string> + <string name="incorrect">Fejl</string> + <string name="pin_too_short">PIN-koden skal være 4 cifre eller længere</string> + <string name="will_remove_pin">Vil fjerne PIN</string> + <string name="image_cache_size">Størrelse på billeddiskcache (MB)</string> + <string name="view_options">Vis muligheder</string> + <string name="columns">Kolonner</string> + <string name="spacing">Mellemrum</string> + <string name="aspect_ratio">Aspektforhold</string> + <string name="show_details">Vis detaljer</string> + <string name="image_type">Billedtype</string> + <string name="cast_and_crew"><![CDATA[Skuespillere og filmhold]]></string> + <string name="guest_stars">Gæsteroller</string> + <string name="edge_size">Kantstørrelse</string> + <string name="refresh_rate_switching">Skift af billedopdateringsfrekvens</string> + <string name="automatic">Automatisk</string> + <string name="username_or_password">Brug brugernavn/adgangskode</string> + <string name="login">Log ind</string> + <string name="general">Generelt</string> + <string name="container">Container</string> + <string name="title">Titel</string> + <string name="codec">Codec</string> + <string name="profile">Profil</string> + <string name="level">Niveau</string> + <string name="resolution">Opløsning</string> + <string name="anamorphic">Anamorfisk</string> + <string name="interlaced">Interlaced</string> + <string name="framerate">Billedfrekvens</string> + <string name="bit_depth">Bitdybde</string> + <string name="video_range">Videoområde</string> + <string name="video_range_type">Type af videoområde</string> + <string name="color_space">Farverum</string> + <string name="color_transfer">Farveoverførsel</string> + <string name="color_primaries">Primære farver</string> + <string name="pixel_format">Pixel-format</string> + <string name="ref_frames">Referencerammer</string> + <string name="nal">NAL</string> + <string name="language">Sprog</string> + <string name="layout">Layout</string> + <string name="channels">Kanaler</string> + <string name="sample_rate">Samplingsfrekvens</string> + <string name="avc">AVC</string> + <string name="yes">Ja</string> + <string name="no">Nej</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Vis titler</string> + <string name="live_tv_repeat">Gentag</string> + <string name="favorite_channels_at_beginning">Vis favoritkanaler først</string> + <string name="sort_channels_recently_watched">Sorter kanaler efter nyligt set</string> + <string name="color_code_programs">Farvekod programmer</string> + <string name="subtitle_delay">Undertekstforsinkelse</string> + <string name="backdrop_display">Stil for baggrundsbillede</string> + <string name="resolution_switching">Skift af opløsning</string> + <string name="local">Lokal</string> + <string name="play_trailer">Afspil trailer</string> + <string name="no_trailers">Ingen trailere</string> + <string name="clear_track_choices">Ryd valg af spor</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Direkte afspilning af Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorerer kontrol af enhedskompatibilitet</string> + <string name="discover">Opdag</string> + <string name="request">Anmod</string> + <string name="pending">Afventer</string> + <string name="seerr_integration">Seerr integration</string> + <string name="remove_seerr_server">Fjern Seerr server</string> + <string name="seerr_server_added">Seerr server tilføjet</string> + <string name="quick_connect">Quick Connect</string> + <string name="quick_connect_summary">Godkend en anden enhed til at logge ind på din konto</string> + <string name="quick_connect_code">Indtast Quick Connect-kode</string> + <string name="quick_connect_code_error">Koden skal være på 6 cifre</string> + <string name="quick_connect_success">Enheden er godkendt</string> + <string name="password">Adgangskode</string> + <string name="username">Brugernavn</string> + <string name="url">URL</string> + <string name="trending">Populært lige nu</string> + <string name="upcoming_movies">Kommende film</string> + <string name="upcoming_tv">Kommende tv-serier</string> + <string name="request_4k">Anmod i 4K</string> + <string name="software_decoding_av1">AV1 software afkodning</string> + <string name="upgrade_mpv_toast">MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne.</string> + <string name="hdr_subtitle_style">HDR-undertekststil</string> + <string name="image_subtitle_opacity">Billedunderteksters gennemsigtighed</string> + <string name="brightness">Lysstyrke</string> + <string name="contrast">Kontrast</string> + <string name="saturation">Mætning</string> + <string name="hue">Farvetone</string> + <string name="red">Rød</string> + <string name="green">Grøn</string> + <string name="blue">Blå</string> + <string name="blur">Sløring</string> + <string name="save_for_album">Gem i album</string> + <string name="play_slideshow">Afspil diasshow</string> + <string name="stop_slideshow">Stop diasshow</string> + <string name="slideshow_at_beginning">Fra begyndelsen</string> + <string name="no_more_images">Ikke flere billeder</string> + <string name="rotate_left">Roter til venstre</string> + <string name="rotate_right">Roter til højre</string> + <string name="zoom_in">Zoom ind</string> + <string name="zoom_out">Zoom ud</string> + <string name="slideshow_duration">Diasshowvarighed</string> + <string name="play_videos_during_slideshow">Afspil videoer under diasshow</string> + <string name="send_media_info_log_to_server">Send medieinformationslog til serveren</string> + <string name="no_limit">Ingen grænse</string> + <string name="max_days_next_up">Maks. antal dage i Næste</string> + <string name="add_row">Tilføj række</string> + <string name="genres_in">Genrer i %1$s</string> + <string name="recently_released_in">Nyligt udkommet i %1$s</string> + <string name="height">Højde</string> + <string name="apply_all_rows">Anvend på alle rækker</string> + <string name="customize_home">Tilpas startsiden</string> + <string name="home_rows">Rækker på startsiden</string> + <string name="load_from_server">Indlæs fra brugerprofilen på serveren</string> + <string name="save_to_server">Gem i brugerprofilen på serveren</string> + <string name="load_from_web_client">Indlæs fra webklienten</string> + <string name="use_series">Brug seriens billede</string> + <string name="add_row_for">Tilføj række for %1$s</string> + <string name="overwrite_server_settings">Overskriv indstillinger på serveren?</string> + <string name="overwrite_local_settings">Overskriv lokale indstillinger?</string> + <string name="for_episodes">For episoder</string> + <string name="suggestions_for">Forslag til %1$s</string> + <string name="increase_all_cards_size">Øg størrelsen på alle kort</string> + <string name="decrease_all_cards_size">Reducer størrelsen på alle kort</string> + <string name="use_thumb_images">Brug thumb-billeder</string> + <string name="settings_saved">Indstillinger gemt</string> + <string name="display_presets">Skærmforudindstillinger</string> + <string name="display_presets_description">Indbyggede forudindstillinger til hurtigt at style alle rækker</string> + <string name="customize_home_summary">Vælg rækker og billeder på startsiden</string> + <string name="commercial">Reklame</string> </resources> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 013f5ccd..628c465a 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -199,12 +199,12 @@ <string name="italic_font">Kursivschrift</string> <string name="runtime_sort">Laufzeit</string> <plurals name="theme_songs"> - <item quantity="one">Theme Songs</item> - <item quantity="other"/> + <item quantity="one">Titelsong</item> + <item quantity="other">Titelsongs</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Theme Videos</item> - <item quantity="other"/> + <item quantity="one">Titelvideo</item> + <item quantity="other">Titelvideos</item> </plurals> <plurals name="clips"> <item quantity="one">Clip</item> @@ -224,11 +224,11 @@ </plurals> <plurals name="shorts"> <item quantity="one">Shorts</item> - <item quantity="other"/> + <item quantity="other">Kurzvideos</item> </plurals> <string name="combine_continue_next_summary">Trifft nur auf Serien zu</string> - <string name="direct_play_ass">Direct play ASS subtitles</string> - <string name="direct_play_pgs">Direct play PGS subtitles</string> + <string name="direct_play_ass">Libass für ASS Untertitel verwenden</string> + <string name="direct_play_pgs">Direktwiedergabe PGS Untertitel</string> <string name="downmix_stereo">Immer zu Stereo heruntermischen</string> <string name="ffmpeg_extension_pref">FFmpeg decoder Modul verwenden</string> <string name="global_content_scale">Standard Skalierung</string> @@ -251,8 +251,8 @@ <string name="font_size">Schriftgröße</string> <string name="font_color">Schriftfarbe</string> <string name="font_opacity">Schriftart Deckkraft</string> - <string name="edge_style">Rand Stil</string> - <string name="edge_color">Rand Farbe</string> + <string name="edge_style">Stil der Umrandung</string> + <string name="edge_color">Farbe der Umrandung</string> <string name="background_opacity">Hintergrund Deckktraft</string> <string name="background_style">Hintergrund Stil</string> <string name="subtitle_style">Untertitel Stil</string> @@ -329,7 +329,7 @@ <string name="use_server_credentials">Login via Server</string> <string name="press_enter_to_confirm">Drücke die mittlere Taste zur Bestätigung</string> <string name="image_cache_size">Größe des Festplatten-Caches für Bilder (MB)</string> - <string name="cast_and_crew">Besetzung & Mitwirkende</string> + <string name="cast_and_crew"><![CDATA[Besetzung & Mitwirkende]]></string> <string name="container">Container</string> <string name="codec">Codec</string> <string name="level">Level</string> @@ -365,6 +365,7 @@ <string name="request_4k">In 4K anfragen</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -432,4 +433,122 @@ <string name="play_videos_during_slideshow">Videos während der Diashow abspielen</string> <string name="max_days_next_up">Maximale Tage in \"Als nächstes\"</string> <string name="quick_connect">Quick Connect</string> + <plurals name="minutes"> + <item quantity="one">%s Minute</item> + <item quantity="other">%s Minuten</item> + </plurals> + <string name="interlaced">Interlaced</string> + <string name="nal">NAL</string> + <string name="quick_connect_summary">Gewähre anderen Geräten Zugriff auf deinen Account</string> + <string name="quick_connect_code">Eingabe Quick Connect Code</string> + <string name="no_limit">Keine Beschränkung</string> + <string name="add_row">Reihe hinzufügen</string> + <string name="genres_in">Genres in %1$s</string> + <string name="recently_released_in">kürzlich veröffentlicht in %1$s</string> + <string name="height">Höhe</string> + <string name="apply_all_rows">Auf alle Reihen anwenden</string> + <string name="add_row_for">Reihe hinzufügen für %1$s</string> + <string name="overwrite_server_settings">Überschreibe Einstellungen auf dem Server?</string> + <string name="overwrite_local_settings">Überschreibe lokale Einstellungen?</string> + <string name="suggestions_for">Vorschläge für %1$s</string> + <string name="send_media_info_log_to_server">Sende Media Info Log zum Server</string> + <string name="customize_home">Anpassung Homepage</string> + <string name="for_episodes">Für Episoden</string> + <string name="display_presets_description">Vorlagen um schnell alle Reihen zu gestalten</string> + <string name="customize_home_summary">Wähle Reihen und Bilder auf der Homepage</string> + <string name="start_after">Starte anschließend</string> + <string name="display_preset_series_thumb">Serien Vorschaubilder</string> + <string name="display_preset_episode_thumbnails">Episoden Vorschaubilder</string> + <string name="seerr_login">Einloggen zum Seerr Server</string> + <string name="seerr_jellyfin_user">Jellyfin User</string> + <string name="show_media_management">Zeige Media Management Optionen</string> + <string name="decrease_all_cards_size">Größe für alle Karten reduzieren</string> + <string name="settings_saved">Einstellungen gespeichert</string> + <string name="max_age_rating">Maximale Altersfreigabe</string> + <string name="no_max">Keine Begrenzung</string> + <string name="for_all_ages">Ohne Altersbeschränkung</string> + <string name="up_to_age">Ab %s Jahre</string> + <string name="screensaver">Bildschirmschoner</string> + <string name="screensaver_settings">Bildschirmschoner-Einstellungen</string> + <string name="start_screensaver">Bildschirmschoner starten</string> + <string name="duration">Länge</string> + <string name="photos">Fotos</string> + <string name="include_types">Arten einschließen</string> + <string name="include_types_summary">Für welche Arten von Einträgen sollen Bilder angezeigt werden</string> + <string name="content_scale_fit">Angepasst</string> + <string name="content_scale_crop">Zugeschnitten</string> + <string name="content_scale_fill">Ausgefüllt</string> + <string name="skip_ignore">Ignorieren</string> + <string name="skip_automatically">Automatisch überspringen</string> + <string name="skip_ask">Vor dem Überspringen nachfragen</string> + <string name="ffmpeg_fallback">FFmpeg nur verwenden, sofern keine anderen Decoder vorhanden sind</string> + <string name="ffmpeg_prefer">FFmpeg statt der vorhandenen Decoder verwenden</string> + <string name="ffmpeg_never">FFmpeg Decoder nie verwenden</string> + <string name="next_up_playback_end">Am Ende einer Playlist</string> + <string name="next_up_outro">Während der Credits/Outro</string> + <string name="white">Weiß</string> + <string name="light_gray">Hellgrau</string> + <string name="dark_gray">Dunkelgrau</string> + <string name="yellow">Gelb</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Umrandung</string> + <string name="subtitle_edge_shadow">Schatten</string> + <string name="prefer_mpv">MPV bevorzugen</string> + <string name="player_backend_options_subtitles_prefer_mpv">ExoPlayer für die HDR Wiedergabe verwenden</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrat (1:1)</string> + <string name="image_type_primary">Primär</string> + <string name="backdrop_style_dynamic">Bild mit dynamischen Farben</string> + <string name="api_key">API Schlüssel</string> + <string name="seerr_local_user">Lokaler Benutzer</string> + <string name="search_for">Suche %s</string> + <string name="display_presets">Design-Vorlagen</string> + <string name="animate">Animieren</string> + <string name="content_scale_fill_width">Breite ausfüllen</string> + <string name="content_scale_fill_height">Höhe ausfüllen</string> + <string name="display_preset_default">Wholphin Standard</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="volume_lowest">Niedrigste</string> + <string name="volume_low">Niedrig</string> + <string name="volume_medium">mittel</string> + <string name="volume_high">Hoch</string> + <string name="volume_full">Voll</string> + <string name="purple">Lila</string> + <string name="orange">Orange</string> + <string name="black">Schwarz</string> + <string name="backdrop_style_image">Nur Bild</string> + <string name="series_continueing">Aktuell</string> + <string name="in_app_screensaver">Eingebauten Bildschirmschoner nutzen</string> + <string name="delete_item">Bist du sicher, dass du diesen Eintrag löschen möchtest?</string> + <string name="actor">Schauspieler</string> + <string name="composer">Komponist</string> + <string name="writer">Autor</string> + <string name="guest_star">Nebenrolle</string> + <string name="producer">Produzent</string> + <string name="conductor">Dirigent</string> + <string name="lyricist">Liedtext-Schreiber</string> + <string name="artist">Künstler</string> + <string name="favorite_items">%s als Favorit speichern</string> + <string name="home_rows">Startseite - Reihen</string> + <string name="load_from_server">Vom Server Benutzerprofil laden</string> + <string name="save_to_server">Im Server Benutzerprofil speichern</string> + <string name="load_from_web_client">Vom Webclient laden</string> + <string name="increase_all_cards_size">Größe für alle Karten erhöhen</string> + <string name="use_thumb_images">Vorschaubilder benutzen</string> + <string name="no_subtitles_found">Es wurden keine Untertitel gefunden</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingenieur</string> + <string name="bold_blue">Dunkelblau</string> + <string name="image_subtitle_opacity">Untertitel-Transparenz</string> + <string name="image_type_thumb">Vorschaubild</string> + <string name="use_series">Serien Vorschaubilder verwenden</string> + <string name="enter_url_api_key"><![CDATA[URL & API Key eingeben]]></string> + <string name="search_and_download_subtitles"><![CDATA[Untertitel suchen & herunterladen]]></string> + <string name="creator">Ersteller</string> + <string name="background_style_wrap">Gestreckt</string> + <string name="background_style_boxed">Zentriert</string> + <string name="mixer">Mischer</string> </resources> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 831c6036..e228ac4d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,7 +12,7 @@ <string name="choose_stream">Elegir %1$s</string> <string name="collection">Colección</string> <string name="collections">Colecciones</string> - <string name="commercial">Comercial</string> + <string name="commercial">Anuncio</string> <string name="confirm">Confirmar</string> <string name="continue_watching">Continuar viendo</string> <string name="decimal_seconds">%.2f segundos</string> @@ -35,7 +35,7 @@ <string name="name">Nombre</string> <string name="no_results">Sin resultados</string> <string name="none">Ninguno</string> - <string name="outro">Outro</string> + <string name="outro">Créditos finales</string> <string name="people">Personas</string> <string name="play">Reproducir</string> <string name="playback">Reproducción</string> @@ -80,7 +80,7 @@ <string name="create_playlist">Crear nueva lista de reproducción</string> <string name="add_to_playlist">Agregar a la lista de reproducción</string> <string name="one_click_pause">Pausar con un clic</string> - <string name="one_click_pause_summary_on">Presiona el centro del D-Pad para pausar/reproducir</string> + <string name="one_click_pause_summary_on">Pulsa el botón central de la cruceta para reproducir/pausar</string> <string name="success">Éxito</string> <string name="runtime_sort">Duración</string> <string name="extras">Extras</string> @@ -119,7 +119,7 @@ <string name="discovered_servers">Servidores detectados</string> <string name="download_and_update"><![CDATA[Descargar y actualizar]]></string> <string name="downloading">Descargando…</string> - <string name="enter_server_url">Introduce la IP o URL del servidor, incluido el puerto</string> + <string name="enter_server_url">Introduce la IP o URL del servidor</string> <string name="error_loading_collection">Error cargando la colección %1$s</string> <string name="forced_track">Forzado</string> <string name="go_to_series">Ir a la serie</string> @@ -264,8 +264,8 @@ <string name="skip_back_preference">Retroceder</string> <string name="skip_commercials_behavior">Comportamiento al saltar anuncios</string> <string name="skip_forward_preference">Avanzar</string> - <string name="skip_intro_behavior">Comportamiento al saltar la intro</string> - <string name="skip_outro_behavior">Comportamiento al saltar el outro</string> + <string name="skip_intro_behavior">Comportamiento al saltar la cabecera</string> + <string name="skip_outro_behavior">Comportamiento al saltar créditos finales</string> <string name="skip_previews_behavior">Comportamiento al saltar avances</string> <string name="skip_recap_behavior">Comportamiento al saltar resumen</string> <string name="update_available">Actualización disponible</string> @@ -291,8 +291,8 @@ <string name="skip_segment_commercial">Saltar anuncios</string> <string name="skip_segment_preview">Saltar avance</string> <string name="skip_segment_recap">Saltar resumen</string> - <string name="skip_segment_outro">Saltar outro</string> - <string name="skip_segment_intro">Saltar intro</string> + <string name="skip_segment_outro">Saltar créditos finales</string> + <string name="skip_segment_intro">Saltar cabecera</string> <string name="played">Reproducido</string> <string name="filter">FIltrar</string> <string name="year">Año</string> @@ -401,6 +401,7 @@ <string name="clear_track_choices">Opciones de pista claras</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -462,8 +463,8 @@ <string name="apply_all_rows">Aplicar a todas las filas</string> <string name="customize_home">Personalizar página de inicio</string> <string name="home_rows">Filas de inicio</string> - <string name="load_from_server">Cargar desde el servidor</string> - <string name="save_to_server">Guardar en el servidor</string> + <string name="load_from_server">Cargar perfil de usuario desde el servidor</string> + <string name="save_to_server">Guardar perfil de usuario en el servidor</string> <string name="load_from_web_client">Cargar del cliente web</string> <string name="use_series">Usar imágenes de la serie</string> <string name="add_row_for">Agregar fila para %1$s</string> @@ -479,4 +480,93 @@ <string name="display_presets_description">Preajustes integrados para estilizar todas las filas</string> <string name="customize_home_summary">Elegir filas e imágenes en la página de inicio</string> <string name="recently_released_in">Lanzado recientemente en %1$s</string> + <string name="favorite_items">Favoritos%s</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Recortar</string> + <string name="content_scale_fill">Rellenar</string> + <string name="content_scale_fill_width">Ajustar al ancho</string> + <string name="content_scale_fill_height">Ajustar al alto</string> + <string name="display_preset_series_thumb">Miniatura de la serie</string> + <string name="display_preset_episode_thumbnails">Miniatura del episodio</string> + <string name="volume_lowest">Mínimo</string> + <string name="volume_low">Bajo</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Morado</string> + <string name="orange">Naranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Negro</string> + <string name="display_preset_default">Póster</string> + <string name="display_preset_compact">Póster compacto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Omitir automáticamente</string> + <string name="skip_ask">Preguntar para omitir</string> + <string name="ffmpeg_fallback">Usar FFmpeg solo si no existe un decodificador integrado</string> + <string name="ffmpeg_prefer">Preferir FFmpeg sobre los decodificadores integrados</string> + <string name="ffmpeg_never">No usar nunca decodificadores FFmpeg</string> + <string name="next_up_playback_end">Al finalizar la reproducción</string> + <string name="next_up_outro">Durante los créditos finales</string> + <string name="white">Blanco</string> + <string name="light_gray">Gris claro</string> + <string name="dark_gray">Gris oscuro</string> + <string name="yellow">Amarillo</string> + <string name="cyan">Cian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Ajustado al texto</string> + <string name="background_style_boxed">Enmarcado</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reproducir contenido HDR</string> + <string name="aspect_ratios_poster">Póster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Cuadrada (1:1)</string> + <string name="image_type_primary">Primaria</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Imagen con color dinámico</string> + <string name="backdrop_style_image">Imagen</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">Clave API</string> + <string name="seerr_login">Iniciar sesión en el servidor Seerr</string> + <string name="seerr_jellyfin_user">Usuario de Jellyfin</string> + <string name="seerr_local_user">Usuario local</string> + <string name="search_and_download_subtitles"><![CDATA[Buscar y descargar subtítulos]]></string> + <string name="no_subtitles_found">No se encontraron subtítulos externos</string> + <string name="series_continueing">Actualidad</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Comenzar después de</string> + <string name="animate">Animación</string> + <string name="max_age_rating">Clasificación por edad máxima</string> + <string name="for_all_ages">Para todas las edades</string> + <string name="up_to_age">Menores de %s</string> + <string name="screensaver">Salvapantallas</string> + <string name="screensaver_settings">Configuración de salvapantallas</string> + <string name="start_screensaver">Iniciar salvapantallas</string> + <string name="duration">Duración</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Qué tipos de elementos mostrar</string> + <string name="in_app_screensaver">Usar salvapantallas de la aplicación</string> + <string name="no_max">Sin límite</string> + <string name="actor">Intérprete</string> + <string name="composer">Compositor</string> + <string name="writer">Guionista</string> + <string name="guest_star">Estrella invitada</string> + <string name="producer">Productor</string> + <string name="conductor">Director musical</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arreglista</string> + <string name="engineer">Ingeniero de sonido</string> + <string name="mixer">Ingeniero de mezcla</string> + <string name="creator">Creador</string> + <string name="artist">Artista</string> + <string name="delete_item">¿Estás seguro de que quieres eliminar este elemento?</string> + <string name="show_media_management">Mostrar opciones de gestión de medios</string> + <string name="search_for">Buscar %s</string> </resources> diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index d4d5d17f..85a803a8 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -367,6 +367,7 @@ <string name="only_forced_subtitles">Vaid sundkorras kuvatud subtiitrid</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -446,8 +447,8 @@ <string name="apply_all_rows">Rakenda kõikidele ridadele</string> <string name="customize_home">Kohanda kodulehte</string> <string name="home_rows">Kodulehe/avalehe read</string> - <string name="load_from_server">Laadi serverist</string> - <string name="save_to_server">Salvesta serverisse</string> + <string name="load_from_server">Laadi kasutajaprofiil serverist</string> + <string name="save_to_server">Salvesta kasutajaprofiil serverisse</string> <string name="load_from_web_client">Laadi veebikliendist</string> <string name="use_series">Kasuta sarja pilti</string> <string name="add_row_for">Lisa rida: %1$s</string> @@ -462,4 +463,5 @@ <string name="display_presets">Kuvamise eelseadistused</string> <string name="display_presets_description">Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks</string> <string name="customize_home_summary">Vali kodulehe/avalehe read ja pildid</string> + <string name="favorite_items">Märgi %s lemmikuks</string> </resources> diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ae405a53..312caa8e 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -221,8 +221,8 @@ <string name="font_opacity">Opacité de la police</string> <string name="edge_style">Style des contours</string> <string name="edge_color">Couleur des contours</string> - <string name="background_opacity">Opacité de l’arrière-plan</string> - <string name="background_style">Style de l’arrière-plan</string> + <string name="background_opacity">Opacité de l\'arrière-plan</string> + <string name="background_style">Style de l\'arrière-plan</string> <string name="subtitle_style">Style des sous-titres</string> <string name="background_color">Couleur de fond</string> <string name="reset">Réinitialiser</string> @@ -234,10 +234,10 @@ <string name="exoplayer_options">Options ExoPlayer</string> <string name="skip_segment_unknown">Passer le segment</string> <string name="skip_segment_commercial">Passer les publicités</string> - <string name="skip_segment_preview">Passer l’aperçu</string> + <string name="skip_segment_preview">Passer l\'aperçu</string> <string name="skip_segment_recap">Passer le récapitulatif</string> <string name="skip_segment_outro">Passer le générique de fin</string> - <string name="skip_segment_intro">Passer l’intro</string> + <string name="skip_segment_intro">Passer l\'intro</string> <string name="played">Lu</string> <string name="filter">Filtre</string> <string name="year">Année</string> @@ -275,7 +275,7 @@ <string name="play_theme_music">Lire la musique de thème</string> <string name="skip_back_on_resume_preference">Revenir en arrière à la reprise de la lecture</string> <string name="skip_commercials_behavior">Comportement de saut des publicités</string> - <string name="skip_intro_behavior">Comportement de saut de l’intro</string> + <string name="skip_intro_behavior">Comportement de saut de l\'intro</string> <string name="skip_outro_behavior">Comportement de saut du générique de fin</string> <string name="skip_previews_behavior">Comportement de saut des aperçus</string> <string name="skip_recap_behavior">Comportement de saut du récapitulatif</string> @@ -289,7 +289,7 @@ <item quantity="many"/> <item quantity="other"/> </plurals> - <string name="combine_continue_next"><![CDATA[Combiner "Continuer à regarder" & "À suivre"]]></string> + <string name="combine_continue_next"><![CDATA[Combiner \"Continuer à regarder\" & \"À suivre\"]]></string> <string name="download_and_update"><![CDATA[Télécharger & Mettre à jour]]></string> <string name="search_and_download"><![CDATA[Rechercher & Télécharger]]></string> <string name="tv_dvr_schedule">Calendrier du DVR</string> @@ -382,6 +382,7 @@ <string name="no_trailers">Pas de bandes-annonces</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -463,8 +464,8 @@ <string name="apply_all_rows">S\'applique à toutes les lignes</string> <string name="customize_home">Personnaliser la page d\'accueil</string> <string name="home_rows">Lignes d\'accueil</string> - <string name="load_from_server">Charger depuis le serveur</string> - <string name="save_to_server">Enregistrer sur le serveur</string> + <string name="load_from_server">Charger à partir du profil utilisateur du serveur</string> + <string name="save_to_server">Enregistrer dans le profil utilisateur du serveur</string> <string name="load_from_web_client">Charger à partir du client Web</string> <string name="use_series">Utiliser l\'image de la série</string> <string name="add_row_for">Ajouter une ligne pour %1$s</string> @@ -479,4 +480,90 @@ <string name="display_presets">Afficher les préréglages</string> <string name="display_presets_description">Préréglages intégrés pour styliser rapidement toutes les lignes</string> <string name="customize_home_summary">Choisissez des lignes et des images sur la page d\'accueil</string> + <string name="favorite_items">Favori %s</string> + <plurals name="minutes"> + <item quantity="one">%s minute</item> + <item quantity="many">%s minutes</item> + <item quantity="other">%s minute</item> + </plurals> + <string name="start_after">Démarrer après</string> + <string name="animate">Animer</string> + <string name="max_age_rating">Classification par âge maximale</string> + <string name="no_max">Pas de maximum</string> + <string name="for_all_ages">Pour tous les âges</string> + <string name="up_to_age">Jusqu\'à l\'âge de %s</string> + <string name="screensaver">Écran de veille</string> + <string name="screensaver_settings">Paramètres de l\'économiseur d\'écran</string> + <string name="start_screensaver">Lancer l\'économiseur d\'écran</string> + <string name="duration">Durée</string> + <string name="photos">Photos</string> + <string name="include_types">Inclure les types</string> + <string name="include_types_summary">Quels types d\'éléments afficher des images pour</string> + <string name="content_scale_fit">Ajustement</string> + <string name="content_scale_crop">Recadrer</string> + <string name="content_scale_fill">Remplir</string> + <string name="content_scale_fill_width">Largeur de remplissage</string> + <string name="content_scale_fill_height">Hauteur de remplissage</string> + <string name="display_preset_default">Wholphin Par défaut</string> + <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_series_thumb">Images miniatures de la série</string> + <string name="display_preset_episode_thumbnails">Images miniatures de l\'épisode</string> + <string name="volume_lowest">Le plus bas</string> + <string name="volume_low">Faible</string> + <string name="volume_medium">Moyen</string> + <string name="volume_high">Haute</string> + <string name="volume_full">Complet</string> + <string name="purple">Violet</string> + <string name="orange">Orange</string> + <string name="bold_blue">Bleu gras</string> + <string name="black">Noir</string> + <string name="skip_ignore">Ignorer</string> + <string name="skip_automatically">Passer automatiquement</string> + <string name="skip_ask">Demander à passer</string> + <string name="ffmpeg_fallback">N\'utilisez FFmpeg que si aucun décodeur intégré n\'existe</string> + <string name="ffmpeg_prefer">Préférez utiliser FFmpeg plutôt que les décodeurs intégrés</string> + <string name="ffmpeg_never">N\'utilisez jamais les décodeurs FFmpeg</string> + <string name="next_up_playback_end">À la fin de la lecture</string> + <string name="next_up_outro">Pendant le générique de fin/outro</string> + <string name="white">Blanc</string> + <string name="light_gray">Gris clair</string> + <string name="dark_gray">Gris foncé</string> + <string name="yellow">Jaune</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contour</string> + <string name="subtitle_edge_shadow">Ombre</string> + <string name="background_style_wrap">Envelopper</string> + <string name="background_style_boxed">Encadré</string> + <string name="prefer_mpv">Préférer MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Utiliser ExoPlayer pour la lecture HDR</string> + <string name="aspect_ratios_poster">Affiche (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Carré (1:1)</string> + <string name="image_type_primary">Primaire</string> + <string name="image_type_thumb">Miniature</string> + <string name="backdrop_style_dynamic">Image avec couleur dynamique</string> + <string name="backdrop_style_image">Image uniquement</string> + <string name="enter_url_api_key">Entrez l\'URL et la clé API</string> + <string name="api_key">Clé API</string> + <string name="seerr_login">Se connecter au serveur Seerr</string> + <string name="seerr_jellyfin_user">Utilisateur Jellyfin</string> + <string name="seerr_local_user">Utilisateur local</string> + <string name="search_and_download_subtitles">Rechercher et télécharger des sous-titres</string> + <string name="no_subtitles_found">Aucun sous-titre distant n\'a été trouvé</string> + <string name="series_continueing">Présent</string> + <string name="in_app_screensaver">Utiliser l\'économiseur d\'écran intégré</string> + <string name="actor">Acteur</string> + <string name="composer">Compositeur</string> + <string name="writer">Écrivain</string> + <string name="guest_star">Invité spécial</string> + <string name="producer">Producteur</string> + <string name="conductor">Conducteur</string> + <string name="lyricist">Parolier</string> + <string name="arranger">Arrangeur</string> + <string name="engineer">Ingénieur</string> + <string name="mixer">Mélangeur</string> + <string name="creator">Créateur</string> + <string name="artist">Artiste</string> </resources> diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 91708cf7..dc94ff96 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -242,8 +242,8 @@ <string name="font_size">Ukuran font</string> <string name="font_color">Warna font</string> <string name="font_opacity">Transparansi font</string> - <string name="edge_style">Jenis garis tepi</string> - <string name="edge_color">Warna garis tepi</string> + <string name="edge_style">Jenis pinggiran</string> + <string name="edge_color">Warna pinggiran</string> <string name="background_opacity">Transparansi latar belakang</string> <string name="background_style">Gaya latar belakang</string> <string name="subtitle_style">Gaya subtitel</string> @@ -318,7 +318,7 @@ <string name="require_pin_code">Membutuhkan PIN untuk profil</string> <string name="will_remove_pin">PIN akan dihapus</string> <string name="view_options">Opsi tampilan</string> - <string name="edge_size">Ukuran garis tepi</string> + <string name="edge_size">Ukuran pinggiran</string> <string name="automatic">Otomatis</string> <string name="username_or_password">Gunakan nama pengguna/kata sandi</string> <string name="subtitle_margin">Batas tepi</string> @@ -369,6 +369,7 @@ <string name="clear_track_choices">Hapus pilihan trek</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -429,8 +430,8 @@ <string name="apply_all_rows">Terapkan ke semua baris</string> <string name="customize_home">Kustomisasi beranda</string> <string name="home_rows">Baris beranda</string> - <string name="load_from_server">Muat dari server</string> - <string name="save_to_server">Simpan ke server</string> + <string name="load_from_server">Muat dari profil pengguna server</string> + <string name="save_to_server">Simpan ke profil pengguna server</string> <string name="load_from_web_client">Muat dari klien web</string> <string name="use_series">Gunakan gambar seri</string> <string name="add_row_for">Tambah baris untuk %1$s</string> @@ -445,4 +446,80 @@ <string name="display_presets">Preset tampilan</string> <string name="display_presets_description">Preset bawaan untuk kustomisasi cepat semua baris</string> <string name="customize_home_summary">Pilih baris dan gambar di halaman beranda</string> + <string name="favorite_items">Favoritkan %s</string> + <string name="artist">Artis</string> + <plurals name="minutes"> + <item quantity="other">%s menit</item> + </plurals> + <string name="start_after">Mulai setelah</string> + <string name="animate">Animasi</string> + <string name="max_age_rating">Rating usia maks</string> + <string name="no_max">Tidak ada maks</string> + <string name="for_all_ages">Untuk semua umur</string> + <string name="up_to_age">Maks. usia %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Pengaturan screensaver</string> + <string name="start_screensaver">Mulai screensaver</string> + <string name="duration">Durasi</string> + <string name="photos">Foto</string> + <string name="include_types">Sertakan jenis</string> + <string name="include_types_summary">Tampilkan gambar untuk jenis item</string> + <string name="display_preset_default">Wholphin Bawaan</string> + <string name="display_preset_compact">Wholphin Ringkas</string> + <string name="display_preset_series_thumb">Gambar Thumbnail Serial</string> + <string name="display_preset_episode_thumbnails">Gambar Thumbnail Episode</string> + <string name="volume_lowest">Terendah</string> + <string name="volume_low">Rendah</string> + <string name="volume_medium">Sedang</string> + <string name="volume_high">Tinggi</string> + <string name="volume_full">Penuh</string> + <string name="purple">Ungu</string> + <string name="orange">Oranye</string> + <string name="bold_blue">Biru Tegas</string> + <string name="black">Hitam</string> + <string name="skip_ignore">Abaikan</string> + <string name="skip_automatically">Lewati otomatis</string> + <string name="skip_ask">Tanya sebelum melewati</string> + <string name="ffmpeg_fallback">Gunakan FFmpeg hanya jika dekoder bawaan tidak ada</string> + <string name="ffmpeg_prefer">Utamakan FFmpeg daripada dekoder bawaan</string> + <string name="ffmpeg_never">Jangan pernah gunakan dekoder FFmpeg</string> + <string name="next_up_playback_end">Di akhir pemutaran</string> + <string name="next_up_outro">Selama kredit akhir/outro</string> + <string name="white">Putih</string> + <string name="light_gray">Abu-abu muda</string> + <string name="dark_gray">Abu-abu tua</string> + <string name="yellow">Kuning</string> + <string name="cyan">Sian</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Garis luar</string> + <string name="prefer_mpv">Utamakan MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Gunakan ExoPlayer untuk pemutaran HDR</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Persegi (1:1)</string> + <string name="image_type_primary">Utama</string> + <string name="image_type_thumb">Thumbnail</string> + <string name="backdrop_style_dynamic">Gambar dengan warna dinamis</string> + <string name="backdrop_style_image">Hanya gambar</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Masuk ke server Seerr</string> + <string name="seerr_jellyfin_user">Pengguna Jellyfin</string> + <string name="seerr_local_user">Pengguna lokal</string> + <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> + <string name="no_subtitles_found">Subtitel remote tidak ditemukan</string> + <string name="series_continueing">Tayang</string> + <string name="in_app_screensaver">Gunakan screensaver dalam aplikasi</string> + <string name="actor">Aktor</string> + <string name="composer">Komposer</string> + <string name="writer">Penulis</string> + <string name="guest_star">Bintang tamu</string> + <string name="producer">Produser</string> + <string name="conductor">Konduktor</string> + <string name="lyricist">Penulis lirik</string> + <string name="arranger">Aransemen</string> + <string name="engineer">Teknisi</string> + <string name="mixer">Mixer</string> + <string name="creator">Kreator</string> </resources> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0aadb5bd..596d898f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -383,6 +383,7 @@ <string name="only_forced_subtitles">Solo sottotitoli forzati</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -461,7 +462,7 @@ <string name="recently_released_in">Recentemente rilasciato in %1$s</string> <string name="height">Altezza</string> <string name="apply_all_rows">Applica a tutte le righe</string> - <string name="load_from_server">Carica dal server</string> + <string name="load_from_server">Carica dal profilo utente del server</string> <string name="add_row_for">Aggiungi riga per %1$s</string> <string name="overwrite_server_settings">Sovrascrivere le impostazioni sul server?</string> <string name="overwrite_local_settings">Sovrascrivere le impostazioni locali?</string> @@ -475,8 +476,97 @@ <string name="home_rows">Righe home</string> <string name="for_episodes">Per gli episodi</string> <string name="customize_home">Personalizza home page</string> - <string name="save_to_server">Salva sul server</string> + <string name="save_to_server">Salva nel profilo utente del server</string> <string name="load_from_web_client">Carica dal client web</string> <string name="use_series">Usa immagine delle serie</string> <string name="customize_home_summary">Scegli righe e immagini nella home page</string> + <string name="favorite_items">Preferito %s</string> + <string name="content_scale_fit">Adatta</string> + <string name="content_scale_crop">Ritaglia</string> + <string name="content_scale_fill">Riempi</string> + <string name="content_scale_fill_width">Riempi larghezza</string> + <string name="content_scale_fill_height">Riempi altezza</string> + <string name="display_preset_default">Wholphin predefinito</string> + <string name="display_preset_compact">Wholphin Compatto</string> + <string name="display_preset_series_thumb">Immagini miniatura serie</string> + <string name="display_preset_episode_thumbnails">Immagini miniatura episodio</string> + <string name="volume_lowest">Minimo</string> + <string name="volume_low">Basso</string> + <string name="volume_medium">Medio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Massimo</string> + <string name="purple">Viola</string> + <string name="orange">Arancione</string> + <string name="bold_blue">Blu intenso</string> + <string name="black">Nero</string> + <string name="skip_ignore">Ignora</string> + <string name="skip_automatically">Salta automaticamente</string> + <string name="skip_ask">Chiedi se saltare</string> + <string name="ffmpeg_fallback">Usa FFmpeg solo se non esiste un decodificatore integrato</string> + <string name="ffmpeg_prefer">Preferisci FFmpeg ai decoder integrati</string> + <string name="ffmpeg_never">Non usare mai i decoder FFmpeg</string> + <string name="next_up_playback_end">Alla fine della riproduzione</string> + <string name="next_up_outro">Durante i titoli di coda</string> + <string name="white">Bianco</string> + <string name="light_gray">Grigio chiaro</string> + <string name="dark_gray">Grigio scuro</string> + <string name="yellow">Giallo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Ombra</string> + <string name="background_style_wrap">Avvolgi</string> + <string name="background_style_boxed">Con riquadro</string> + <string name="prefer_mpv">Preferisci MVP</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usa ExoPlayer per la riproduzione HDR</string> + <string name="aspect_ratios_poster">Copertina (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrato (1:1)</string> + <string name="image_type_primary">Principale</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Immagine con colore dinamico</string> + <string name="backdrop_style_image">Solo immagine</string> + <string name="enter_url_api_key"><![CDATA[Inserisci URL e chiave API]]></string> + <string name="api_key">Chiave API</string> + <string name="seerr_login">Accedi al server Seerr</string> + <string name="seerr_jellyfin_user">Utente Jellyfin</string> + <string name="seerr_local_user">Utente locale</string> + <string name="search_and_download_subtitles"><![CDATA[Cerca e scarica sottotitoli]]></string> + <string name="no_subtitles_found">Nessun sottotitolo remoto trovato</string> + <string name="series_continueing">In corso</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minuti</item> + <item quantity="other">%s minuti</item> + </plurals> + <string name="start_after">Inizia dopo</string> + <string name="animate">Anima</string> + <string name="max_age_rating">Età massima</string> + <string name="for_all_ages">Per tutte le età</string> + <string name="up_to_age">Fino a %s anni</string> + <string name="screensaver">Salvaschermo</string> + <string name="screensaver_settings">Impostazioni salvaschermo</string> + <string name="start_screensaver">Avvia salvaschermo</string> + <string name="duration">Durata</string> + <string name="photos">Foto</string> + <string name="include_types">Includi tipi</string> + <string name="include_types_summary">Quali tipi di elementi mostrare nelle immagini</string> + <string name="no_max">Nessun massimo</string> + <string name="in_app_screensaver">Usa salvaschermo dell\'app</string> + <string name="composer">Compositore</string> + <string name="writer">Sceneggiatore</string> + <string name="actor">Attore</string> + <string name="producer">Produttore</string> + <string name="guest_star">Ospite speciale</string> + <string name="conductor">Direttore d’orchestra</string> + <string name="lyricist">Paroliere</string> + <string name="arranger">Arrangiatore</string> + <string name="engineer">Ingegnere del suono</string> + <string name="mixer">Tecnico di mixaggio</string> + <string name="artist">Artista</string> + <string name="creator">Creatore</string> + <string name="search_for">Cerca %s</string> + <string name="delete_item">Sei sicuro di voler cancellare questo elemento?</string> + <string name="show_media_management">Mostra opzioni di gestione dei media</string> </resources> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c7889af3..94da1f4d 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -31,4 +31,434 @@ <string name="director">Diretor</string> <string name="disabled">Desabilitado</string> <string name="discovered_servers">Servidores encontrados</string> + <string name="downloading">Baixando…</string> + <string name="enabled">Habilitado</string> + <string name="ends_at">Termina às %1$s</string> + <string name="enter_server_url">Insira o IP ou URL do Servidor</string> + <string name="enter_server_address">Insira o endereço do servidor</string> + <string name="episodes">Episódios</string> + <string name="error_loading_collection">Erro ao carregar a coleção %1$s</string> + <string name="external_track">Externo</string> + <string name="favorites">Favoritos</string> + <string name="file_size">Tamanho</string> + <string name="forced_track">Forçado</string> + <string name="genres">Gêneros</string> + <string name="go_to_series">Ir para séries</string> + <string name="go_to">Ir Para</string> + <string name="hide_controller_timeout">Ocultar controles de reprodução</string> + <string name="hide_debug_info">Ocultar informações de depuração</string> + <string name="hide">Ocultar</string> + <string name="home">Início</string> + <string name="immediate">Imediato</string> + <string name="intro">Introdução</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> + <string name="library">Biblioteca</string> + <string name="license_info">Informação de licenças</string> + <string name="live_tv">TV ao Vivo</string> + <string name="loading">Carregando…</string> + <string name="mark_entire_series_as_played">Marcar série inteira como reproduzida?</string> + <string name="mark_entire_series_as_unplayed">Marcar séria inteira como não reproduzida?</string> + <string name="mark_unwatched">Marcar como não assistida</string> + <string name="mark_watched">Marcar como assistida</string> + <string name="max_bitrate">Taxa de bits máxima</string> + <string name="more_like_this">Mais como isso</string> + <string name="more">Mais</string> + <plurals name="movies"> + <item quantity="one">Filmes</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="name">Nome</string> + <string name="next_up">A Seguir</string> + <string name="no_data">Sem dados</string> + <string name="no_results">Sem resultados</string> + <string name="no_servers_found">Nenhum servidor encontrado</string> + <string name="no_scheduled_recordings">Nenhuma gravação agendada</string> + <string name="no_update_available">Nenhuma atualização disponível</string> + <string name="none">Nenhum(a)</string> + <string name="only_forced_subtitles">Apenas Legendas Forçadas</string> + <string name="outro">Finalização</string> + <string name="path">Caminho</string> + <plurals name="people"> + <item quantity="one">Pessoas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="play_count">Contagem de Reproduções</string> + <string name="play_from_here">Reproduzir a partir daqui</string> + <string name="play">Reproduzir</string> + <string name="playback_debug_info">Mostrar informações de depuração da reprodução</string> + <string name="playback_speed">Velocidade de Reprodução</string> + <string name="playback">Reprodução</string> + <string name="playlist">Lista de reprodução</string> + <string name="playlists">Listas de reprodução</string> + <string name="preview">Prévia</string> + <string name="profile_specific_settings">Configurações do Perfil de Usuário</string> + <string name="queue">Fila</string> + <string name="recap">Recapitulação</string> + <string name="recently_added_in">Recentemente adicionado em %1$s</string> + <string name="recently_added">Recentemente adicionado</string> + <string name="recently_recorded">Recentemente Gravado</string> + <string name="recently_released">Recentemente Publicado</string> + <string name="recommended">Recomendado</string> + <string name="record_program">Gravar Programa</string> + <string name="record_series">Gravar Série</string> + <string name="remove_favorite">Desfavoritar</string> + <string name="restart">Reiniciar</string> + <string name="resume">Continuar</string> + <string name="save">Salvar</string> + <string name="search">Procurar</string> + <string name="searching">Procurando…</string> + <string name="voice_search">Pesquisa por voz</string> + <string name="voice_starting">Iniciando</string> + <string name="voice_search_prompt">Fale para pesquisar</string> + <string name="processing">Processando</string> + <string name="press_back_to_cancel">Aperte voltar para cancelar</string> + <string name="voice_error_audio">Erro de gravação de áudio</string> + <string name="voice_error_client">Erro de reconhecimento de voz</string> + <string name="voice_error_permissions">Permissão de microfone necessária</string> + <string name="voice_error_network">Erro de rede</string> + <string name="voice_error_network_timeout">Tempo limite de rede esgotado</string> + <string name="voice_error_no_match">Nenhuma fala reconhecida</string> + <string name="voice_error_busy">Reconhecimento de voz ocupado</string> + <string name="voice_error_server">Erro de servidor</string> + <string name="voice_error_speech_timeout">Nenhuma fala detectada</string> + <string name="voice_error_unknown">Erro desconhecido</string> + <string name="voice_error_start_failed">Falha no início do reconhecimento</string> + <string name="voice_error_timeout">Tempo limite do reconhecimento de voz esgotado</string> + <string name="retry">Tentar novamente</string> + <string name="select_server">Selecionar Servidor</string> + <string name="select_user">Selecionar Usuário</string> + <string name="show_debug_info">Mostrar informações de depuração</string> + <string name="show_next_up_when">Mostrar próximo</string> + <string name="show">Mostrar</string> + <string name="shuffle">Aleatório</string> + <string name="skip">Pular</string> + <string name="sort_by_date_added">Data de Adição</string> + <string name="sort_by_date_episode_added">Data de Adição do Episódio</string> + <string name="sort_by_date_played">Data de Reprodução</string> + <string name="sort_by_date_released">Data de Lançamento</string> + <string name="sort_by_name">Nome</string> + <string name="sort_by_random">Aleatório</string> + <string name="studios">Estúdios</string> + <string name="submit">Enviar</string> + <string name="subtitle_download_too_long">O download está demorando muito, talvez você tenha que reiniciar a reprodução</string> + <string name="subtitle">Legenda</string> + <string name="subtitles">Legendas</string> + <string name="suggestions">Sugestões</string> + <string name="switch_servers">Trocar de servidores</string> + <string name="switch_user">Trocar</string> + <string name="top_unwatched">Não assistidos com melhor avaliação</string> + <string name="trailer">Trailer</string> + <plurals name="trailers"> + <item quantity="one">Trailers</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="tv_dvr_schedule">Agenda do DVR</string> + <string name="tv_guide">Guia</string> + <string name="tv_season">Temporada</string> + <string name="tv_seasons">Temporadas</string> + <plurals name="tv_shows"> + <item quantity="one">Programas de TV</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="ui_interface">Interface</string> + <string name="unknown">Desconhecido</string> + <string name="updates">Atualizações</string> + <string name="version">Versão</string> + <string name="video_scale">Escala de Vídeo</string> + <string name="video">Vídeo</string> + <string name="videos">Vídeos</string> + <string name="watch_live">Assista ao vivo</string> + <string name="years_old">%1$d anos de idade</string> + <string name="play_with_transcoding">Reproduzir com transcodificação</string> + <string name="media_information">Informação de Mídia</string> + <string name="show_clock">Mostrar Relógio</string> + <string name="aired_episode_order">Ordem de Lançamento do Episódio</string> + <string name="create_playlist">Criar nova lista de reprodução</string> + <string name="add_to_playlist">Adicionar a lista de reprodução</string> + <string name="one_click_pause">Pausar com um clique</string> + <string name="one_click_pause_summary_on">Aperte o centro do controle direcional para pausar/reproduzir</string> + <string name="italic_font">Colocar a fonte em itálico</string> + <string name="font">Fonte</string> + <string name="background">Plano de fundo</string> + <string name="success">Sucesso</string> + <string name="official_rating">Classificação Parental</string> + <string name="runtime_sort">Duração</string> + <string name="extras">Extras</string> + <plurals name="other_extras"> + <item quantity="one">Outro</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Por Trás das Cenas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Músicas Tema</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Vídeos Tema</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="clips"> + <item quantity="one">Clipes</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Cenas Deletadas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="interviews"> + <item quantity="one">Entrevistas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="scenes"> + <item quantity="one">Cenas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="samples"> + <item quantity="one">Amostras</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="shorts"> + <item quantity="one">Curtas</item> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="favorite_items">Favoritar %s</string> + <plurals name="downloads"> + <item quantity="one">%s download</item> + <item quantity="many">%s downloads</item> + <item quantity="other">%s downloads</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hora</item> + <item quantity="many">%d horas</item> + <item quantity="other">%d horas</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s dia</item> + <item quantity="many">%s dias</item> + <item quantity="other">%s dias</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d item</item> + <item quantity="many">%d itens</item> + <item quantity="other">%d itens</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d segundo</item> + <item quantity="many">%d segundos</item> + <item quantity="other">%d segundos</item> + </plurals> + <string name="ac3_supported">Dispositivo suporta AC3/Dolby Digital</string> + <string name="advanced_settings">Configurações Avançadas</string> + <string name="advanced_ui">IU Avançada</string> + <string name="app_theme">Tema do Aplicativo</string> + <string name="auto_check_for_updates">Checar atualizações automaticamente</string> + <string name="auto_play_next_delay">Atraso antes da próxima reprodução</string> + <string name="auto_play_next">Reproduzir próximo automaticamente</string> + <string name="check_for_updates">Checar atualizações</string> + <string name="combine_continue_next_summary">Aplicar apenas a Séries de TV</string> + <string name="direct_play_ass">Reproduzir legendas ASS diretamente</string> + <string name="direct_play_pgs">Reproduzir legendas PGS diretamente</string> + <string name="downmix_stereo">Sempre mixar para estéreo</string> + <string name="ffmpeg_extension_pref">Usar módulo de decodificação do FFmpeg</string> + <string name="global_content_scale">Escala padrão do conteúdo</string> + <string name="install_update">Instalar atualização</string> + <string name="installed_version">Versão instalada</string> + <string name="max_homepage_items">Máximo de itens nas linhas da página inicial</string> + <string name="nav_drawer_pins_summary">Escolher itens exibidos por padrão, os outros serão ocultados</string> + <string name="nav_drawer_pins">Personalizar os Itens da Gaveta de Navegação</string> + <string name="nav_drawer_switch_on_focus_summary_off">Clicar para trocar de páginas</string> + <string name="nav_drawer_switch_on_focus">Trocar a página de navegação no foco</string> + <string name="pass_out_protection">Proteção Contra Desmaios</string> + <string name="play_theme_music">Tocar música tema</string> + <string name="playback_overrides">Sobreposições de reprodução</string> + <string name="remember_selected_tab">Lembrar abas selecionadas</string> + <string name="rewatch_next_up">Habilitar reassistir em a seguir</string> + <string name="seek_bar_steps">Passos da barra de busca</string> + <string name="send_app_logs_summary">Útil para depuração</string> + <string name="send_app_logs">Enviar os registros do aplicativo para o servidor atual</string> + <string name="send_crash_reports_summary">Tentativa de envio para o último servidor conectado</string> + <string name="send_crash_reports">Enviar Relatórios de Falhas</string> + <string name="settings">Configurações</string> + <string name="skip_commercials_behavior">Comportamento de pular os comerciais</string> + <string name="skip_intro_behavior">Comportamento de pular as introduções</string> + <string name="skip_outro_behavior">Comportamento de pular a finalização</string> + <string name="skip_previews_behavior">Comportamento de pular as prévias</string> + <string name="skip_recap_behavior">Comportamento de pular as recapitulações</string> + <string name="update_available">Atualização disponível</string> + <string name="update_url_summary">URL usada para verificação de atualizações</string> + <string name="update_url">URL de atualização</string> + <string name="font_size">Tamanho da fonte</string> + <string name="font_color">Cor da fonte</string> + <string name="font_opacity">Opacidade da fonte</string> + <string name="edge_style">Estilo da borda</string> + <string name="edge_color">Cor da borda</string> + <string name="background_opacity">Opacidade do fundo</string> + <string name="background_style">Estilo do fundo</string> + <string name="subtitle_style">Estilo da legenda</string> + <string name="background_color">Cor do fundo</string> + <string name="reset">Redefinir</string> + <string name="bold_font">Fonte em negrito</string> + <string name="player_backend">Backend de reprodução</string> + <string name="mpv_hardware_decoding">MPV: Usar decodificação baseada em hardware</string> + <string name="disable_if_crash">Desabilite se encontrar falhas</string> + <string name="mpv_options">Opções do MPV</string> + <string name="exoplayer_options">Opções do ExPlayer</string> + <string name="skip_segment_unknown">Pular Segmento</string> + <string name="skip_segment_commercial">Pular Anúncios</string> + <string name="skip_segment_preview">Pular Prévia</string> + <string name="skip_segment_recap">Pular Recapitulação</string> + <string name="skip_segment_outro">Pular Finalização</string> + <string name="skip_segment_intro">Pular Introdução</string> + <string name="played">Reproduzido</string> + <string name="filter">Filtro</string> + <string name="year">Ano</string> + <string name="decade">Década</string> + <string name="remove">Remover</string> + <string name="dolby_vision">Dolby Vision</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="mpv_use_gpu_next">MPV: Usar gpu-next</string> + <string name="mpv_conf">Editar mpv.conf</string> + <string name="discard_change">Descartar mudanças?</string> + <string name="subtitle_margin">Margem</string> + <string name="verbose_logging">Registro extenso</string> + <string name="enter_pin">Insira o PIN</string> + <string name="sign_in_auto">Entrar automaticamente</string> + <string name="use_server_credentials">Autenticar via servidor</string> + <string name="press_enter_to_confirm">Aperte o botão central para confirmar</string> + <string name="require_pin_code">Exigir PIN para o perfil</string> + <string name="confirm_pin">Confirmar o PIN</string> + <string name="incorrect">Incorreto</string> + <string name="pin_too_short">PIN deve ter 4 ou mais dígitos</string> + <string name="will_remove_pin">Irá remover o PIN</string> + <string name="image_cache_size">Tamanho do cache de imagens no disco (MB)</string> + <string name="view_options">Ver opções</string> + <string name="columns">Colunas</string> + <string name="spacing">Espaçamento</string> + <string name="aspect_ratio">Proporção da Tela</string> + <string name="show_details">Mostrar detalhes</string> + <string name="image_type">Tipo de imagem</string> + <string name="guest_stars">Estrelas convidadas</string> + <string name="edge_size">Tamanho da borda</string> + <string name="refresh_rate_switching">Troca de taxa de atualização</string> + <string name="automatic">Automático</string> + <string name="username_or_password">Usar nome de usuário / senha</string> + <string name="login">Autenticação</string> + <string name="general">Geral</string> + <string name="container">Contêiner</string> + <string name="title">Título</string> + <string name="codec">Codec</string> + <string name="profile">Perfil</string> + <string name="level">Nível</string> + <string name="resolution">Resolução</string> + <string name="anamorphic">Anamórfico</string> + <string name="interlaced">Entrelaçado</string> + <string name="framerate">Taxa de atualização</string> + <string name="bit_depth">Profundidade de bits</string> + <string name="video_range">Alcance de vídeo</string> + <string name="video_range_type">Tipo de alcance de vídeo</string> + <string name="color_space">Espaço de cores</string> + <string name="color_transfer">Transferência de cores</string> + <string name="color_primaries">Primárias de cores</string> + <string name="pixel_format">Formato de pixel</string> + <string name="ref_frames">Quadros de referência</string> + <string name="language">Idioma</string> + <string name="layout">Layout</string> + <string name="channels">Canais</string> + <string name="sample_rate">Taxa de amostra</string> + <string name="yes">Sim</string> + <string name="no">Não</string> + <string name="sdr">SDR</string> + <string name="hdr">HDR</string> + <string name="hdr10">HDR10</string> + <string name="hdr10_plus">HDR10+</string> + <string name="hlg">HLG</string> + <string name="bit_unit">bit</string> + <string name="sample_rate_unit">Hz</string> + <string name="show_titles">Mostrar títulos</string> + <string name="live_tv_repeat">Repetir</string> + <string name="favorite_channels_at_beginning">Mostrar canais favoritos primeiro</string> + <string name="sort_channels_recently_watched">Ordenar canais por recentemente assistidos</string> + <string name="color_code_programs">Codificar programas por cor</string> + <string name="subtitle_delay">Atraso na legenda</string> + <string name="backdrop_display">Estilo de fundo</string> + <string name="resolution_switching">Troca de resolução</string> + <string name="local">Local</string> + <string name="play_trailer">Reproduzir trailer</string> + <string name="no_trailers">Nenhum trailer</string> + <string name="clear_track_choices">Limpar escolhas de faixas</string> + <string name="dts_x">DTS:X</string> + <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> + <string name="truehd">TrueHD</string> + <string name="dolby_digital">DD</string> + <string name="dolby_digital_plus">DD+</string> + <string name="dts">DTS</string> + <string name="force_dovi_profile_7">Reprodução direta de Dolby Vision Profile 7</string> + <string name="force_dovi_profile_7_summary">Ignorar checagens de compatibilidade de dispositivo</string> + <string name="discover">Descobrir</string> + <string name="request">Solicitar</string> + <string name="pending">Pendente</string> + <string name="seerr_integration">Integração com o Seerr</string> + <string name="remove_seerr_server">Remover Servidor Seerr</string> + <string name="seerr_server_added">Servidor Seerr adicionado</string> + <string name="quick_connect">Conexão Rápida</string> + <string name="quick_connect_summary">Autorizar outro dispositivo a se autenticar com sua conta</string> + <string name="quick_connect_code">Insira o código de conexão rápida</string> + <string name="quick_connect_code_error">O código deve ter 6 dígitos</string> + <string name="quick_connect_success">Dispositivo autorizado com sucesso</string> + <string name="password">Senha</string> + <string name="username">Nome de usuário</string> + <string name="url">URL</string> + <string name="trending">Tendências</string> + <string name="upcoming_movies">Filmes Lançados em Breve</string> + <string name="upcoming_tv">Programas de TV Lançados em Breve</string> + <string name="request_4k">Solicitar em 4K</string> + <string name="software_decoding_av1">Decodificação de AV1 via software</string> + <string name="upgrade_mpv_toast">MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações.</string> + <string name="hdr_subtitle_style">Estilo da legenda HDR</string> + <string name="image_subtitle_opacity">Opacidade da legenda de imagem</string> + <string name="brightness">Brilho</string> + <string name="contrast">Contraste</string> + <string name="saturation">Saturação</string> + <string name="hue">Matiz</string> + <string name="red">Vermelho</string> + <string name="green">Verde</string> + <string name="blue">Azul</string> + <string name="blur">Desfoque</string> + <string name="play_slideshow">Reproduzir apresentação de slides</string> + <string name="stop_slideshow">Interromper apresentação de slides</string> + <string name="slideshow_at_beginning">No começo</string> + <string name="no_more_images">Sem mais fotos</string> + <string name="rotate_left">Girar a esquerda</string> + <string name="rotate_right">Girar a direita</string> + <string name="zoom_in">Mais zoom</string> + <string name="zoom_out">Menos zoom</string> + <string name="slideshow_duration">Duração da reprodução de slides</string> + <string name="play_videos_during_slideshow">Reproduzir vídeos ao longo da apresentação de slides</string> + <string name="send_media_info_log_to_server">Enviar registro de informações da mídia ao servidor</string> + <string name="no_limit">Sem limite</string> + <string name="max_days_next_up">Máximo de dias no A Seguir</string> + <string name="add_row">Adicionar linha</string> + <string name="genres_in">Gêneros em %1$s</string> + <string name="recently_released_in">Recentemente lançado em %1$s</string> + <string name="height">Altura</string> + <string name="apply_all_rows">Aplicar a todas as linhas</string> + <string name="customize_home">Personalizar página inicial</string> </resources> diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 1fb5fbb5..b4f44186 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -121,7 +121,7 @@ <string name="top_unwatched">Mais Votados Não Assistidos</string> <string name="trailer">Trailer</string> <plurals name="trailers"> - <item quantity="one">Trailers</item> + <item quantity="one">Trailer</item> <item quantity="many">Trailers</item> <item quantity="other">Trailers</item> </plurals> @@ -382,6 +382,7 @@ <string name="clear_track_choices">Apagar seleções de faixas</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -463,8 +464,8 @@ <string name="apply_all_rows">Aplicar a todas as linhas</string> <string name="customize_home">Personalizar página inicial</string> <string name="home_rows">Linhas da página inicial</string> - <string name="load_from_server">Carregar do servidor</string> - <string name="save_to_server">Gravar para o servidor</string> + <string name="load_from_server">Carregar do perfil de utilizador do servidor</string> + <string name="save_to_server">Guardar no perfil de utilizador do servidor</string> <string name="load_from_web_client">Carregar a partir do cliente web</string> <string name="use_series">Usar imagem da série</string> <string name="add_row_for">Adicionar linha para %1$s</string> @@ -479,4 +480,90 @@ <string name="display_presets_description">Predefinições embarcadas para definir todas as linhas rapidamente</string> <string name="customize_home_summary">Escolha as linhas e imagens na página inicial</string> <string name="decrease_all_cards_size">Diminuir o tamanho de todos os cartões</string> + <string name="favorite_items">Favoritos %s</string> + <plurals name="minutes"> + <item quantity="one">%s minuto</item> + <item quantity="many">%s minutos</item> + <item quantity="other">%s minutos</item> + </plurals> + <string name="start_after">Começar depois</string> + <string name="animate">Animado</string> + <string name="max_age_rating">Idade máxima</string> + <string name="no_max">Sem máximo</string> + <string name="for_all_ages">Para todas as idades</string> + <string name="up_to_age">Até a idade %s</string> + <string name="screensaver">Proteção de ecrã</string> + <string name="screensaver_settings">Definições de protecção de ecrã</string> + <string name="start_screensaver">Iniciar protecção de ecrã</string> + <string name="duration">Duração</string> + <string name="photos">Fotos</string> + <string name="include_types">Incluir tipos</string> + <string name="include_types_summary">Que tipos de itens devem mostrar imagens</string> + <string name="content_scale_fit">Ajustar</string> + <string name="content_scale_crop">Recortar</string> + <string name="content_scale_fill">Preencher</string> + <string name="content_scale_fill_width">Preencher largura</string> + <string name="content_scale_fill_height">Preencher altura</string> + <string name="display_preset_default">Padrão do Wholphin</string> + <string name="display_preset_compact">Wholphin Compacto</string> + <string name="display_preset_series_thumb">Imagens em miniatura da série</string> + <string name="display_preset_episode_thumbnails">Imagens em miniatura do episódio</string> + <string name="volume_lowest">Mínimo</string> + <string name="volume_low">Baixo</string> + <string name="volume_medium">Médio</string> + <string name="volume_high">Alto</string> + <string name="volume_full">Máximo</string> + <string name="purple">Roxo</string> + <string name="orange">Laranja</string> + <string name="bold_blue">Azul intenso</string> + <string name="black">Preto</string> + <string name="skip_ignore">Ignorar</string> + <string name="skip_automatically">Saltar automaticamente</string> + <string name="skip_ask">Pedir para saltar</string> + <string name="ffmpeg_fallback">Usa FFmpeg apenas se não houver um descodificador integrado</string> + <string name="ffmpeg_prefer">Prefere o FFmpeg aos descodificadores integrados</string> + <string name="ffmpeg_never">Nunca usar os descodificadores FFmpeg</string> + <string name="next_up_playback_end">No final da reprodução</string> + <string name="next_up_outro">Durante os créditos finais</string> + <string name="white">Branco</string> + <string name="light_gray">Cinzento claro</string> + <string name="dark_gray">Cinzento escuro</string> + <string name="yellow">Amarelo</string> + <string name="cyan">Ciano</string> + <string name="magenta">Magenta</string> + <string name="subtitle_edge_outline">Contorno</string> + <string name="subtitle_edge_shadow">Sombra</string> + <string name="background_style_wrap">Envolver</string> + <string name="background_style_boxed">Com caixa</string> + <string name="prefer_mpv">Preferir MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">Usar ExoPlayer para reprodução HDR</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Quadrado (1:1)</string> + <string name="image_type_primary">Primária</string> + <string name="image_type_thumb">Miniatura</string> + <string name="backdrop_style_dynamic">Imagem com cor dinâmica</string> + <string name="backdrop_style_image">Apenas imagem</string> + <string name="enter_url_api_key"><![CDATA[Inserir URL e Chave API]]></string> + <string name="api_key">Chave API</string> + <string name="seerr_login">Iniciar sessão no servidor Seerr</string> + <string name="seerr_jellyfin_user">Utilizador Jellyfin</string> + <string name="seerr_local_user">Utilizador local</string> + <string name="search_and_download_subtitles"><![CDATA[Procurar e transferir legendas]]></string> + <string name="no_subtitles_found">Não foram encontradas legendas remotas</string> + <string name="series_continueing">Em curso</string> + <string name="in_app_screensaver">Usar protetor de ecrã da aplicação</string> + <string name="actor">Ator</string> + <string name="composer">Compositor</string> + <string name="writer">Argumentista</string> + <string name="guest_star">Convidado especial</string> + <string name="producer">Produtor</string> + <string name="conductor">Director musicar</string> + <string name="lyricist">Letrista</string> + <string name="arranger">Arranjador</string> + <string name="engineer">Engenheiro de som</string> + <string name="mixer">Técnico de mixagem</string> + <string name="creator">Criador</string> + <string name="artist">Artista</string> </resources> diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index c55a7acc..dfb498ba 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -22,7 +22,7 @@ <string name="confirm">Potvrdiť</string> <string name="continue_watching">Pokračovať v pozeraní</string> <string name="critic_rating">Hodnotenie kritikov</string> - <string name="decimal_seconds">%.1f sekúnd</string> + <string name="decimal_seconds">%.2f sekúnd</string> <string name="default_track">Predvolené</string> <string name="delete">Vymazať</string> <string name="died">Zomrel</string> @@ -102,4 +102,211 @@ <string name="select_user">Vybrať používateľa</string> <string name="show_debug_info">Zobraziť informácie o debugu</string> <string name="compose_error_message_title">Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server.</string> + <string name="ends_at">Končí o %1$s</string> + <string name="enter_server_address">Zadajte adresu servera</string> + <string name="no_servers_found">Nenašli sa žiadne servery</string> + <string name="only_forced_subtitles">Iba vynútené titulky</string> + <string name="voice_search">Hlasové vyhľadávanie</string> + <string name="voice_starting">Spúšťanie</string> + <string name="voice_search_prompt">Hovorením vyhľadávajte</string> + <string name="processing">Spracovanie</string> + <string name="press_back_to_cancel">Stlačte späť pre zrušenie</string> + <string name="voice_error_audio">Chyba nahrávania zvuku</string> + <string name="voice_error_client">Chyba rozpoznávania hlasu</string> + <string name="voice_error_permissions">Vyžaduje sa povolenie na prístup k mikrofónu</string> + <string name="voice_error_network">Chyba siete</string> + <string name="voice_error_network_timeout">Časový limit siete</string> + <string name="voice_error_no_match">Nebol rozpoznaný žiadny hlas</string> + <string name="voice_error_busy">Rozpoznávanie hlasu je zaneprázdnené</string> + <string name="voice_error_server">Chyba servera</string> + <string name="voice_error_speech_timeout">Nebola zistená žiadna reč</string> + <string name="voice_error_unknown">Neznáma chyba</string> + <string name="voice_error_start_failed">Nepodarilo sa spustiť rozpoznávanie hlasu</string> + <string name="voice_error_timeout">Časový limit rozpoznávania hlasu vypršal</string> + <string name="retry">Skúsiť znova</string> + <string name="show_next_up_when">Zobraziť ďalšie</string> + <string name="show">Zobraziť</string> + <string name="shuffle">Náhodne</string> + <string name="skip">Preskočiť</string> + <string name="sort_by_date_added">Dátum pridania</string> + <string name="sort_by_date_episode_added">Dátum pridania epizódy</string> + <string name="sort_by_date_played">Dátum prehratia</string> + <string name="sort_by_date_released">Dátum vydania</string> + <string name="sort_by_name">Názov</string> + <string name="sort_by_random">Náhodne</string> + <string name="studios">Štúdiá</string> + <string name="submit">Odoslať</string> + <string name="subtitle_download_too_long">Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie</string> + <string name="subtitle">Podnázov</string> + <string name="subtitles">Titulky</string> + <string name="suggestions">Návrhy</string> + <string name="switch_servers">Zmeniť servery</string> + <string name="switch_user">Zmeniť</string> + <string name="top_unwatched">Najlepšie hodnotené Nepozreté</string> + <string name="trailer">Trailer</string> + <plurals name="trailers"> + <item quantity="one">Trailery</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="tv_dvr_schedule">DVR rozvrh</string> + <string name="tv_guide">Sprievodca</string> + <string name="tv_season">Séria</string> + <string name="tv_seasons">Série</string> + <plurals name="tv_shows"> + <item quantity="one">Seriály</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="ui_interface">Rozhranie</string> + <string name="unknown">Neznámy</string> + <string name="updates">Aktualizácie</string> + <string name="version">Verzia</string> + <string name="video_scale">Mierka videa</string> + <string name="video">Video</string> + <string name="videos">Videá</string> + <string name="watch_live">Sledovať naživo</string> + <string name="years_old">%1$d rokov</string> + <string name="play_with_transcoding">Prehrávať s prekódovaním</string> + <string name="media_information">Informácie o médiu</string> + <string name="show_clock">Zobraziť hodiny</string> + <string name="aired_episode_order">Poradie odvysielaných epizód</string> + <string name="create_playlist">Vytvoriť nový playlist</string> + <string name="add_to_playlist">Pridať do playlistu</string> + <string name="one_click_pause">Pozastavenie jedným kliknutím</string> + <string name="one_click_pause_summary_on">Stlačte stred D-Padu pre pozastavenie/prehrávanie</string> + <string name="italic_font">Kurzívový font</string> + <string name="font">Font</string> + <string name="background">Pozadie</string> + <string name="success">Úspech</string> + <string name="official_rating">Rodičovské hodnotenie</string> + <string name="runtime_sort">Trvanie</string> + <string name="extras">Bonusový materiál</string> + <plurals name="other_extras"> + <item quantity="one">Ostatné</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="behind_the_scenes"> + <item quantity="one">Zo zákulisia</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="theme_songs"> + <item quantity="one">Tématické piesne</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="theme_videos"> + <item quantity="one">Tématické videá</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="clips"> + <item quantity="one">Klipy</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="deleted_scenes"> + <item quantity="one">Vymazané scény</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="interviews"> + <item quantity="one">Rozhovory</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="scenes"> + <item quantity="one">Scény</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="samples"> + <item quantity="one">Vzorky</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="featurettes"> + <item quantity="one">Krátkometrážne filmy</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <plurals name="shorts"> + <item quantity="one">Krátke videá</item> + <item quantity="few"/> + <item quantity="many"/> + <item quantity="other"/> + </plurals> + <string name="favorite_items">Obľúbené %s</string> + <plurals name="downloads"> + <item quantity="one">%s stiahnutý</item> + <item quantity="few">%s stiahnuté</item> + <item quantity="many">%s stiahnutých</item> + <item quantity="other">%s stiahnutých</item> + </plurals> + <plurals name="hours"> + <item quantity="one">%d hodina</item> + <item quantity="few">%d hodiny</item> + <item quantity="many">%d hodín</item> + <item quantity="other">%d hodín</item> + </plurals> + <plurals name="days"> + <item quantity="one">%s deň</item> + <item quantity="few">%s dni</item> + <item quantity="many">%s dní</item> + <item quantity="other">%s dní</item> + </plurals> + <plurals name="items"> + <item quantity="one">%d položka</item> + <item quantity="few">%d položky</item> + <item quantity="many">%d položiek</item> + <item quantity="other">%d položiek</item> + </plurals> + <plurals name="seconds"> + <item quantity="one">%d sekunda</item> + <item quantity="few">%d sekundy</item> + <item quantity="many">%d sekúnd</item> + <item quantity="other">%d sekúnd</item> + </plurals> + <string name="ac3_supported">Zariadenie podporuje AC3/Dolby Digital</string> + <string name="advanced_settings">Pokročilé nastavenia</string> + <string name="advanced_ui">Pokročilé UI</string> + <string name="app_theme">Téma aplikácie</string> + <string name="auto_check_for_updates">Automaticky kontrolovať aktualizácie</string> + <string name="check_for_updates">Skontrolovať aktualizácie</string> + <string name="combine_continue_next_summary">Platí len pre seriály</string> + <string name="direct_play_ass">Priame prehrávanie ASS titulkov</string> + <string name="direct_play_pgs">Priame prehrávanie PGS titulkov</string> + <string name="downmix_stereo">Vždy downmixovať do stereo formátu</string> + <string name="ffmpeg_extension_pref">Použiť modul FFmpeg dekodéra</string> + <string name="global_content_scale">Predvolená mierka obsahu</string> + <string name="install_update">Nainštalovať aktualizáciu</string> + <string name="installed_version">Nainštalovaná verzia</string> + <string name="max_homepage_items">Maximálny počet položiek v riadkoch na domovskej obrazovke</string> + <string name="nav_drawer_pins_summary">Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté</string> + <string name="nav_drawer_pins">Prispôsobenie položiek navigačného panela</string> + <string name="nav_drawer_switch_on_focus_summary_off">Kliknutím prepnete stránky</string> + <string name="nav_drawer_switch_on_focus">Prepnúť stránky navigačného panelu pri zaostrení</string> + <string name="pass_out_protection">Ochrana proti výpadku</string> + <string name="play_theme_music">Prehrať tématickú hudbu</string> + <string name="remember_selected_tab">Zapamätať vybrané karty</string> + <string name="seek_bar_steps">Kroky seek baru</string> + <string name="send_app_logs_summary">Užitočné pre debugovanie</string> + <string name="send_app_logs">Odoslať logy aplikácie na aktuálny server</string> + <string name="send_crash_reports_summary">Pokus o odoslanie na posledný pripojený server</string> + <string name="send_crash_reports">Odoslať správy o zlyhaní</string> + <string name="settings">Nastavenia</string> </resources> diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b7d13882..cef8bb90 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -277,4 +277,49 @@ <string name="enter_pin">Skriv in PIN</string> <string name="sign_in_auto">Logga in automatiskt</string> <string name="use_server_credentials">Logga in via server</string> + <string name="enter_server_address">Ange server-adress</string> + <string name="voice_error_network">Nätverksfel</string> + <string name="voice_error_unknown">Okänt fel</string> + <plurals name="minutes"> + <item quantity="one">%s minut</item> + <item quantity="other">%s minuter</item> + </plurals> + <string name="bold_font">Fet stil</string> + <string name="dolby_atmos">Dolby Atmos</string> + <string name="discard_change">Avbryt ändringar?</string> + <string name="require_pin_code">Kräv PIN för profilen</string> + <string name="resolution">Upplösning</string> + <string name="language">Språk</string> + <string name="yes">Ja</string> + <string name="no">Nej</string> + <string name="live_tv_repeat">Upprepa</string> + <string name="password">Lösenord</string> + <string name="username">Användarnamn</string> + <string name="creator">Skapare</string> + <string name="engineer">Ingenjör</string> + <string name="actor">Skådespelare</string> + <string name="composer">Kompositör</string> + <string name="writer">Författare</string> + <string name="producer">Producent</string> + <string name="backdrop_style_image">Endast bild</string> + <string name="white">Vit</string> + <string name="light_gray">Ljusgrå</string> + <string name="dark_gray">Mörkgrå</string> + <string name="yellow">Gul</string> + <string name="purple">Lila</string> + <string name="black">Svart</string> + <string name="for_all_ages">För alla åldrar</string> + <string name="up_to_age">Upp till %s år</string> + <string name="screensaver">Skärmsläckare</string> + <string name="screensaver_settings">Inställningar Skärmsläckare</string> + <string name="start_screensaver">Starta skärmsläckare</string> + <string name="photos">Bilder</string> + <string name="settings_saved">Inställningar sparade</string> + <string name="rotate_left">Rotera vänster</string> + <string name="rotate_right">Rotera höger</string> + <string name="zoom_in">Zooma in</string> + <string name="zoom_out">Zooma ut</string> + <string name="no_limit">Ingen gräns</string> + <string name="add_row">Lägg till rad</string> + <string name="press_back_to_cancel">Tryck bakåt för att avbryta</string> </resources> diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 117d6e2f..4e4c1838 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -52,7 +52,7 @@ <string name="home">Ana Ekran</string> <string name="immediate">Hemen</string> <string name="intro">İntro</string> - <string name="jump_letters">#ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ</string> + <string name="jump_letters">#ABCDEFGHIJKLMNOPQRSTUVWXYZ</string> <string name="library">Kütüphane</string> <string name="license_info">Lisans bilgileri</string> <string name="live_tv">Canlı TV</string> @@ -65,7 +65,7 @@ <string name="more_like_this">Benzerleri</string> <string name="more">Diğer</string> <plurals name="movies"> - <item quantity="one">Filmler</item> + <item quantity="one">Film</item> <item quantity="other">Filmler</item> </plurals> <string name="name">Ad</string> @@ -80,7 +80,7 @@ <string name="outro">Kapanış jeneriği</string> <string name="path">Yol</string> <plurals name="people"> - <item quantity="one">Kişiler</item> + <item quantity="one">Kişi</item> <item quantity="other">Kişiler</item> </plurals> <string name="play_count">Oynatma Sayısı</string> @@ -151,7 +151,7 @@ <string name="top_unwatched">İzlenmemiş En İyiler</string> <string name="trailer">Fragman</string> <plurals name="trailers"> - <item quantity="one">Fragmanlar</item> + <item quantity="one">Fragman</item> <item quantity="other">Fragmanlar</item> </plurals> <string name="tv_dvr_schedule">Kayıt Takvimi</string> @@ -159,7 +159,7 @@ <string name="tv_season">Sezon</string> <string name="tv_seasons">Sezonlar</string> <plurals name="tv_shows"> - <item quantity="one">Diziler</item> + <item quantity="one">Dizi</item> <item quantity="other">Diziler</item> </plurals> <string name="ui_interface">Arayüz</string> @@ -187,48 +187,48 @@ <string name="runtime_sort">Süre</string> <string name="extras">Ekstralar</string> <plurals name="other_extras"> - <item quantity="one">Diğer Ekstralar</item> - <item quantity="other"/> + <item quantity="one">Diğer Ekstra</item> + <item quantity="other">Diğer Ekstralar</item> </plurals> <plurals name="behind_the_scenes"> <item quantity="one">Sahne Arkası</item> - <item quantity="other"/> + <item quantity="other">Sahne Arkaları</item> </plurals> <plurals name="theme_songs"> - <item quantity="one">Tema şarkıları</item> - <item quantity="other"/> + <item quantity="one">Tema şarkısı</item> + <item quantity="other">Tema şarkıları</item> </plurals> <plurals name="theme_videos"> - <item quantity="one">Tema Videoları</item> - <item quantity="other"/> + <item quantity="one">Tema Videosu</item> + <item quantity="other">Tema Videoları</item> </plurals> <plurals name="clips"> - <item quantity="one">Klipler</item> - <item quantity="other"/> + <item quantity="one">Klip</item> + <item quantity="other">Klipler</item> </plurals> <plurals name="deleted_scenes"> - <item quantity="one">Silinmiş Sahneler</item> - <item quantity="other"/> + <item quantity="one">Silinmiş Sahne</item> + <item quantity="other">Silinmiş Sahneler</item> </plurals> <plurals name="interviews"> - <item quantity="one">Röportajlar</item> - <item quantity="other"/> + <item quantity="one">Röportaj</item> + <item quantity="other">Röportajlar</item> </plurals> <plurals name="scenes"> - <item quantity="one">Sahneler</item> - <item quantity="other"/> + <item quantity="one">Sahne</item> + <item quantity="other">Sahneler</item> </plurals> <plurals name="samples"> - <item quantity="one">Örnekler</item> - <item quantity="other"/> + <item quantity="one">Örnek</item> + <item quantity="other">Örnekler</item> </plurals> <plurals name="featurettes"> - <item quantity="one">Tanıtımlar</item> - <item quantity="other"/> + <item quantity="one">Tanıtım</item> + <item quantity="other">Tanıtımlar</item> </plurals> <plurals name="shorts"> - <item quantity="one">Kısa Videolar</item> - <item quantity="other"/> + <item quantity="one">Kısa Video</item> + <item quantity="other">Kısa Videolar</item> </plurals> <plurals name="downloads"> <item quantity="one">%s indirme</item> @@ -240,7 +240,7 @@ </plurals> <plurals name="items"> <item quantity="one">%d öğe</item> - <item quantity="other">%d öğe</item> + <item quantity="other">%d öğeler</item> </plurals> <plurals name="seconds"> <item quantity="one">%d saniye</item> @@ -256,7 +256,7 @@ <string name="check_for_updates">Güncellemeleri kontrol et</string> <string name="combine_continue_next_summary">Yalnızca diziler için geçerlidir</string> <string name="combine_continue_next"><![CDATA[İzlemeye Devam Et ve Sıradaki Bölümleri Birleştir]]></string> - <string name="direct_play_ass">ASS altyazıları doğrudan oynat</string> + <string name="direct_play_ass">ASS altyazıları için libass kullan</string> <string name="direct_play_pgs">PGS altyazıları doğrudan oynat</string> <string name="downmix_stereo">Her zaman Stereo\'ya dönüştür</string> <string name="ffmpeg_extension_pref">FFmpeg kod çözücü kullan</string> @@ -394,6 +394,7 @@ <string name="clear_track_choices">Parça seçimlerini temizle</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -455,8 +456,8 @@ <string name="apply_all_rows">Tüm satırlara uygula</string> <string name="customize_home">Ana ekranı özelleştir</string> <string name="home_rows">Ana ekran satırları</string> - <string name="load_from_server">Sunucudan yükle</string> - <string name="save_to_server">Sunucuya kaydet</string> + <string name="load_from_server">Kullanıcı profilinden yükle</string> + <string name="save_to_server">Kullanıcı profiline kaydet</string> <string name="load_from_web_client">Web istemcisinden yükle</string> <string name="use_series">Dizi görselini kullan</string> <string name="add_row_for">%1$s için satır ekle</string> @@ -470,4 +471,93 @@ <string name="settings_saved">Ayarlar kaydedildi</string> <string name="display_presets">Görüntü ön ayarları</string> <string name="display_presets_description">Tüm satırları hızlıca stilize etmek için hazır ayarlar</string> + <string name="favorite_items">Favori %s</string> + <string name="customize_home_summary">Ana sayfadaki satırları ve görselleri seçin</string> + <string name="content_scale_fit">Sığdır</string> + <string name="content_scale_crop">Kırp</string> + <string name="content_scale_fill">Doldur</string> + <string name="content_scale_fill_width">Genişliği Doldur</string> + <string name="content_scale_fill_height">Yüksekliği Doldur</string> + <string name="display_preset_default">Wholphin Varsayılan</string> + <string name="display_preset_compact">Wholphin Kompakt</string> + <string name="display_preset_series_thumb">Dizi Küçük Resimleri</string> + <string name="display_preset_episode_thumbnails">Bölüm Küçük Resimleri</string> + <string name="volume_lowest">En Düşük</string> + <string name="volume_low">Düşük</string> + <string name="volume_medium">Orta</string> + <string name="volume_high">Yüksek</string> + <string name="volume_full">Tam</string> + <string name="purple">Mor</string> + <string name="orange">Turuncu</string> + <string name="bold_blue">Koyu Mavi</string> + <string name="black">Siyah</string> + <string name="skip_ignore">Yoksay</string> + <string name="skip_automatically">Otomatik Atla</string> + <string name="skip_ask">Atlamak için sor</string> + <string name="ffmpeg_fallback">Dahili çözücü yoksa FFmpeg kullan</string> + <string name="ffmpeg_prefer">Dahili çözücüler yerine FFmpeg\'i tercih et</string> + <string name="ffmpeg_never">FFmpeg çözücülerini asla kullanma</string> + <string name="next_up_playback_end">Oynatma sonunda</string> + <string name="next_up_outro">Kapanış bölümünde / Jenerik sırasında</string> + <string name="white">Beyaz</string> + <string name="light_gray">Açık Gri</string> + <string name="dark_gray">Koyu Gri</string> + <string name="yellow">Sarı</string> + <string name="cyan">Camgöbeği</string> + <string name="magenta">Macenta</string> + <string name="subtitle_edge_outline">Kenarlık</string> + <string name="subtitle_edge_shadow">Gölge</string> + <string name="background_style_wrap">Kapla</string> + <string name="background_style_boxed">Kutu içinde</string> + <string name="prefer_mpv">MPV\'yi tercih et</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR oynatma için ExoPlayer kullan</string> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Kare (1:1)</string> + <string name="image_type_primary">Birincil</string> + <string name="image_type_thumb">Küçük Resim</string> + <string name="backdrop_style_dynamic">Dinamik renkli görsel</string> + <string name="backdrop_style_image">Sadece görsel</string> + <string name="enter_url_api_key"><![CDATA[URL ve API Anahtarını Girin]]></string> + <string name="api_key">API Anahtarı</string> + <string name="seerr_login">Seerr sunucusuna giriş yap</string> + <string name="seerr_jellyfin_user">Jellyfin kullanıcısı</string> + <string name="seerr_local_user">Yerel kullanıcı</string> + <string name="search_and_download_subtitles"><![CDATA[Altyazı ara ve indir]]></string> + <string name="no_subtitles_found">Uzak altyazı bulunamadı</string> + <string name="series_continueing">Mevcut</string> + <plurals name="minutes"> + <item quantity="one">%s dakika</item> + <item quantity="other">%s dakika</item> + </plurals> + <string name="start_after">Başlama süresi</string> + <string name="animate">Oynatma Süresi</string> + <string name="max_age_rating">Maksimum yaş sınırı</string> + <string name="no_max">Sınır yok</string> + <string name="for_all_ages">Genel İzleyici</string> + <string name="up_to_age">%s yaşa kadar</string> + <string name="screensaver">Ekran koruyucu</string> + <string name="screensaver_settings">Ekran koruyucu ayarları</string> + <string name="start_screensaver">Ekran koruyucuyu başlat</string> + <string name="duration">Süre</string> + <string name="photos">Fotoğraflar</string> + <string name="include_types">İçerik türleri</string> + <string name="include_types_summary">Görsel gösterilecek öğe türleri</string> + <string name="in_app_screensaver">Uygulama içi ekran koruyucuyu kullan</string> + <string name="actor">Aktör</string> + <string name="composer">Besteci</string> + <string name="writer">Yazar</string> + <string name="guest_star">Konuk sanatçı</string> + <string name="producer">Yapımcı</string> + <string name="conductor">Orkestra şefi</string> + <string name="lyricist">Söz yazarı</string> + <string name="arranger">Aranjör</string> + <string name="engineer">Mühendis</string> + <string name="mixer">Karıştırıcı</string> + <string name="creator">Yaratıcı</string> + <string name="artist">Sanatçı</string> + <string name="delete_item">Bu öğeyi silmek istediğinizden emin misiniz?</string> + <string name="show_media_management">Medya yönetim seçeneklerini göster</string> + <string name="search_for">Ara %s</string> </resources> diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ea7e0323..f7835121 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -432,6 +432,7 @@ <string name="clear_track_choices">Чіткий вибір доріжки</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -474,4 +475,42 @@ <string name="zoom_out">Зменшити</string> <string name="slideshow_duration">Тривалість слайд-шоу</string> <string name="play_videos_during_slideshow">Відтворення відео під час слайд-шоу</string> + <string name="favorite_items">Улюблені %s</string> + <plurals name="days"> + <item quantity="one">день</item> + <item quantity="few">дні</item> + <item quantity="many">днів</item> + <item quantity="other">днів</item> + </plurals> + <string name="quick_connect">Швидке підключення</string> + <string name="quick_connect_summary">Дозвольте іншому пристрою увійти у ваш обліковий запис</string> + <string name="quick_connect_code">Введіть код швидкого підключення</string> + <string name="quick_connect_code_error">Код повинен складатися з 6 цифр</string> + <string name="quick_connect_success">Пристрій успішно авторизовано</string> + <string name="send_media_info_log_to_server">Надіслати інформацію про медіа на сервер</string> + <string name="no_limit">Без обмежень</string> + <string name="max_days_next_up">Максимальна кількість днів в Очікуються</string> + <string name="add_row">Додати рядок</string> + <string name="genres_in">Жанри %1$s</string> + <string name="recently_released_in">Нещодавно додані %1$s</string> + <string name="height">Висота</string> + <string name="apply_all_rows">Застосувати для всіх рядків</string> + <string name="customize_home">Змінити головну сторінку</string> + <string name="home_rows">Рядки на головній сторінці</string> + <string name="load_from_server">Завантажувати профіль користувача з сереверу</string> + <string name="save_to_server">Зберегти профіль користувача на сервер</string> + <string name="load_from_web_client">Завантажити з веб клієнта</string> + <string name="use_series">Використовувати зображення серіалу</string> + <string name="add_row_for">Додати рядок для %1$s</string> + <string name="overwrite_server_settings">Перезаписати налаштування на сервері?</string> + <string name="overwrite_local_settings">Перезаписати налаштування на цьому пристрої?</string> + <string name="for_episodes">Для епізодів</string> + <string name="suggestions_for">Рекомендації %1$s</string> + <string name="increase_all_cards_size">Збільшити розмір карток</string> + <string name="decrease_all_cards_size">Зменшити розмір карток</string> + <string name="use_thumb_images">Використовувати мініатюру</string> + <string name="settings_saved">Налаштування збережені</string> + <string name="display_presets">Налаштування дисплея</string> + <string name="display_presets_description">Вбудовані налаштування для швидкого оформлення всіх рядків</string> + <string name="customize_home_summary">Обери рядки і зображення для головної сторінки</string> </resources> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 49bab214..b78e3322 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -150,7 +150,7 @@ <string name="font">字体</string> <string name="background">背景</string> <string name="success">成功</string> - <string name="official_rating">年龄分级</string> + <string name="official_rating">内容分级</string> <string name="runtime_sort">播放时长</string> <string name="extras">附加内容</string> <plurals name="other_extras"> @@ -208,7 +208,7 @@ <string name="check_for_updates">检查更新</string> <string name="combine_continue_next_summary">仅适用于电视剧</string> <string name="combine_continue_next"><![CDATA[合并继续观看与即将播放]]></string> - <string name="direct_play_ass">直接播放 ASS 字幕</string> + <string name="direct_play_ass">使用 libass 处理 ASS 字幕</string> <string name="direct_play_pgs">直接播放 PGS 字幕</string> <string name="downmix_stereo">始终降混为立体声</string> <string name="ffmpeg_extension_pref">使用 FFmpeg 解码模块</string> @@ -351,6 +351,7 @@ <string name="only_forced_subtitles">仅强制字幕</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -429,8 +430,8 @@ <string name="apply_all_rows">应用到所有行</string> <string name="customize_home">自定义首页</string> <string name="home_rows">首页行</string> - <string name="load_from_server">从服务器加载</string> - <string name="save_to_server">保存到服务器</string> + <string name="load_from_server">从服务器用户配置加载</string> + <string name="save_to_server">保存到服务器用户配置</string> <string name="load_from_web_client">从网络客户端加载</string> <string name="use_series">使用剧集图片</string> <string name="add_row_for">为 %1$s 添加行</string> @@ -445,4 +446,91 @@ <string name="display_presets">显示预设</string> <string name="display_presets_description">可快速设置所有行样式的内置预设</string> <string name="customize_home_summary">选择首页上的行和图片</string> + <string name="favorite_items">收藏的 %s</string> + <string name="content_scale_fit">适应</string> + <string name="content_scale_crop">裁剪</string> + <string name="content_scale_fill">填充</string> + <string name="content_scale_fill_width">填充宽度</string> + <string name="content_scale_fill_height">填充高度</string> + <string name="display_preset_default">Wholphin 默认</string> + <string name="display_preset_compact">Wholphin 紧凑</string> + <string name="display_preset_series_thumb">剧集缩略图</string> + <string name="display_preset_episode_thumbnails">分集缩略图</string> + <string name="purple">紫色</string> + <string name="orange">橙色</string> + <string name="bold_blue">深蓝色</string> + <string name="black">黑色</string> + <string name="skip_ignore">忽略</string> + <string name="skip_automatically">自动跳过</string> + <string name="skip_ask">询问后跳过</string> + <string name="ffmpeg_fallback">仅在无内置解码器时使用 FFmpeg</string> + <string name="ffmpeg_prefer">优先使用 FFmpeg 解码器而非内置解码器</string> + <string name="ffmpeg_never">从不使用 FFmpeg 解码器</string> + <string name="next_up_playback_end">播放结束时</string> + <string name="next_up_outro">播到片尾时</string> + <string name="white">白色</string> + <string name="light_gray">浅灰色</string> + <string name="dark_gray">深灰色</string> + <string name="yellow">黄色</string> + <string name="cyan">青色</string> + <string name="magenta">紫红色</string> + <string name="subtitle_edge_outline">轮廓</string> + <string name="subtitle_edge_shadow">阴影</string> + <string name="prefer_mpv">优先使用 mpv</string> + <string name="player_backend_options_subtitles_prefer_mpv">HDR 播放使用 ExoPlayer</string> + <string name="aspect_ratios_poster">海报 (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">正方形 (1:1)</string> + <string name="image_type_primary">主图</string> + <string name="image_type_thumb">缩略图</string> + <string name="backdrop_style_dynamic">动态配色图片</string> + <string name="backdrop_style_image">仅图片</string> + <string name="enter_url_api_key"><![CDATA[输入 URL 与 API 密钥]]></string> + <string name="api_key">API 密钥</string> + <string name="seerr_login">登录 Seerr 服务器</string> + <string name="seerr_jellyfin_user">Jellyfin 用户</string> + <string name="seerr_local_user">本地用户</string> + <string name="search_and_download_subtitles"><![CDATA[搜索并下载字幕]]></string> + <string name="no_subtitles_found">未找到远程字幕</string> + <string name="volume_lowest">最低音量</string> + <string name="volume_low">低音量</string> + <string name="volume_medium">中等音量</string> + <string name="volume_high">高音量</string> + <string name="volume_full">最大音量</string> + <string name="background_style_wrap">环绕式</string> + <string name="background_style_boxed">框式</string> + <string name="series_continueing">当前</string> + <plurals name="minutes"> + <item quantity="other">%s 分钟</item> + </plurals> + <string name="animate">动画</string> + <string name="start_after">启动时间</string> + <string name="max_age_rating">最高年龄分级</string> + <string name="no_max">无上限</string> + <string name="for_all_ages">所有年龄</string> + <string name="up_to_age">最高至 %s 岁</string> + <string name="screensaver">屏幕保护程序</string> + <string name="screensaver_settings">屏幕保护程序设置</string> + <string name="start_screensaver">启动屏幕保护程序</string> + <string name="duration">时长</string> + <string name="photos">照片</string> + <string name="include_types">包含类型</string> + <string name="include_types_summary">要显示图片的项目类型</string> + <string name="in_app_screensaver">使用应用内屏幕保护程序</string> + <string name="actor">演员</string> + <string name="composer">作曲</string> + <string name="writer">编剧</string> + <string name="guest_star">客串</string> + <string name="producer">制片</string> + <string name="conductor">指挥</string> + <string name="lyricist">作词</string> + <string name="arranger">编曲</string> + <string name="engineer">录音</string> + <string name="mixer">混音</string> + <string name="creator">主创</string> + <string name="artist">艺人</string> + <string name="show_media_management">显示媒体管理选项</string> + <string name="delete_item">是否确定要删除此项目?</string> + <string name="search_for">搜索 %s</string> </resources> diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1cae12e0..c96c987f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -59,7 +59,7 @@ <string name="tv_guide">指南</string> <string name="updates">更新</string> <string name="version">版本</string> - <string name="video_scale">畫面比例</string> + <string name="video_scale">畫面縮放比例</string> <string name="video">影片</string> <string name="videos">影片</string> <string name="watch_live">觀看直播</string> @@ -104,7 +104,7 @@ <string name="direct_play_ass">直接播放 ASS 字幕</string> <string name="direct_play_pgs">直接播放 PGS 字幕</string> <string name="ffmpeg_extension_pref">使用 FFmpeg 解碼模組</string> - <string name="global_content_scale">預設畫面比例</string> + <string name="global_content_scale">預設縮放比例</string> <string name="install_update">安裝更新</string> <string name="installed_version">安裝版本</string> <string name="max_homepage_items">首頁每列項目上限</string> @@ -349,6 +349,7 @@ <string name="no_trailers">無預告片</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -362,7 +363,7 @@ <string name="remove_seerr_server">移除 Seerr 伺服器</string> <string name="seerr_server_added">已新增 Seerr 伺服器</string> <string name="password">密碼</string> - <string name="username">使用者名稱</string> + <string name="username">帳號</string> <string name="url">URL</string> <string name="trending">當前趨勢</string> <string name="upcoming_movies">即將上映的電影</string> @@ -427,22 +428,109 @@ <string name="suggestions_for">推薦的 %1$s</string> <string name="increase_all_cards_size">放大所有海報尺寸</string> <string name="decrease_all_cards_size">縮小所有海報尺寸</string> - <string name="use_thumb_images">使用橫向縮圖</string> + <string name="use_thumb_images">使用橫式縮圖</string> <string name="add_row">新增一列</string> <string name="apply_all_rows">套用到所有列</string> <string name="home_rows">首頁各列</string> <string name="add_row_for">為 %1$s 新增列項目</string> <string name="display_presets_description">快速設定所有項目為內建預設樣式</string> - <string name="customize_home_summary">選擇首頁要顯示的列項目及圖片樣式</string> + <string name="customize_home_summary">選擇首頁要顯示的項目及圖片樣式</string> <string name="height">高度</string> <string name="customize_home">自訂首頁</string> - <string name="load_from_server">從伺服器載入</string> - <string name="save_to_server">保存到伺服器</string> - <string name="load_from_web_client">從網頁版載入</string> + <string name="load_from_server">從伺服器載入設定</string> + <string name="save_to_server">保存設定到伺服器</string> + <string name="load_from_web_client">從網頁版載入設定</string> <string name="use_series">使用劇集圖片</string> <string name="overwrite_server_settings">覆蓋伺服器上的設定?</string> <string name="overwrite_local_settings">覆蓋本地設定?</string> <string name="settings_saved">設定已儲存</string> <string name="display_presets">顯示預設樣式</string> <string name="for_episodes">針對單集</string> + <string name="content_scale_fit">原比例縮放</string> + <string name="content_scale_crop">裁切填滿</string> + <string name="content_scale_fill">拉伸填滿</string> + <string name="content_scale_fill_width">填滿寬度</string> + <string name="content_scale_fill_height">填滿高度</string> + <string name="volume_lowest">最低音量</string> + <string name="volume_low">低音量</string> + <string name="volume_medium">中等音量</string> + <string name="volume_high">高音量</string> + <string name="volume_full">最大音量</string> + <string name="purple">紫色</string> + <string name="orange">橘色</string> + <string name="bold_blue">深藍色</string> + <string name="black">黑色</string> + <string name="skip_ignore">不處理</string> + <string name="skip_automatically">自動跳過</string> + <string name="skip_ask">詢問是否跳過</string> + <string name="ffmpeg_fallback">僅在無內建解碼器時使用 FFmpeg</string> + <string name="ffmpeg_prefer">優先使用 FFmpeg 而非內建解碼器</string> + <string name="ffmpeg_never">不使用 FFmpeg 解碼器</string> + <string name="next_up_playback_end">在播放結束時</string> + <string name="next_up_outro">在播到片尾時</string> + <string name="white">白色</string> + <string name="light_gray">淺灰色</string> + <string name="dark_gray">深灰色</string> + <string name="yellow">黃色</string> + <string name="cyan">青色</string> + <string name="magenta">桃紅色</string> + <string name="subtitle_edge_outline">外框</string> + <string name="subtitle_edge_shadow">陰影</string> + <string name="background_style_wrap">貼合文字</string> + <string name="background_style_boxed">長方底框</string> + <string name="prefer_mpv">優先使用 MPV</string> + <string name="player_backend_options_subtitles_prefer_mpv">僅 HDR 使用 ExoPlayer 播放</string> + <string name="favorite_items">我的最愛的 %s</string> + <string name="display_preset_default">Wholphin 預設樣式</string> + <string name="display_preset_compact">Wholphin 緊湊樣式</string> + <string name="display_preset_series_thumb">劇集縮圖樣式</string> + <string name="display_preset_episode_thumbnails">單集縮圖樣式</string> + <string name="aspect_ratios_poster">海報 (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">正方形 (1:1)</string> + <string name="image_type_primary">主封面</string> + <string name="image_type_thumb">橫式縮圖</string> + <string name="backdrop_style_dynamic">動態色彩背景</string> + <string name="backdrop_style_image">僅背景圖</string> + <string name="enter_url_api_key"><![CDATA[輸入 URL 及 API 金鑰]]></string> + <string name="api_key">API 金鑰</string> + <string name="seerr_login">登入 Seerr 伺服器</string> + <string name="seerr_jellyfin_user">Jellyfin 帳號</string> + <string name="seerr_local_user">Seerr 帳號</string> + <string name="search_and_download_subtitles"><![CDATA[搜尋和下載字幕]]></string> + <string name="no_subtitles_found">未找到遠端字幕</string> + <string name="series_continueing">至今</string> + <plurals name="minutes"> + <item quantity="other">%s 分鐘</item> + </plurals> + <string name="start_after">啟動時間</string> + <string name="animate">動態效果</string> + <string name="max_age_rating">年齡分級上限</string> + <string name="no_max">不限制</string> + <string name="for_all_ages">適合所有年齡</string> + <string name="up_to_age">最高至 %s 歲</string> + <string name="screensaver">螢幕保護程式</string> + <string name="screensaver_settings">螢幕保護程式設定</string> + <string name="start_screensaver">立即開始</string> + <string name="duration">切換間隔</string> + <string name="photos">照片</string> + <string name="include_types">包含類型</string> + <string name="include_types_summary">要顯示圖片的類型</string> + <string name="in_app_screensaver">使用內建螢幕保護程式</string> + <string name="guest_star">客串演員</string> + <string name="actor">演員</string> + <string name="composer">作曲</string> + <string name="writer">編劇</string> + <string name="producer">製作人</string> + <string name="conductor">指揮</string> + <string name="lyricist">作詞</string> + <string name="arranger">編曲</string> + <string name="engineer">錄音師</string> + <string name="mixer">混音師</string> + <string name="creator">創作者</string> + <string name="artist">演出者</string> + <string name="delete_item">確定要刪除此項目嗎?</string> + <string name="show_media_management">顯示媒體管理選項</string> + <string name="search_for">搜尋 %s</string> </resources> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d966e472..a123eff7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,11 @@ <item quantity="one">%d second</item> <item quantity="other">%d seconds</item> </plurals> + <plurals name="minutes"> + <item quantity="zero">%s minutes</item> + <item quantity="one">%s minute</item> + <item quantity="other">%s minutes</item> + </plurals> <plurals name="movies"> <item quantity="zero">Movies</item> @@ -446,6 +451,7 @@ <string name="clear_track_choices">Clear track choices</string> <string name="dts_x">DTS:X</string> <string name="dts_hd">DTS:HD</string> + <string name="dts_hd_ma">DTS:HD MA</string> <string name="truehd">TrueHD</string> <string name="dolby_digital">DD</string> <string name="dolby_digital_plus">DD+</string> @@ -524,105 +530,198 @@ <string name="customize_home_summary">Choose rows and images on the home page</string> <string name="prefer_ac3_for_surround_summary"><![CDATA[Transcode 2+ channel audio to AC3/Dolby Digital]]></string> <string name="prefer_ac3_for_surround">Use AC3 for surround sound audio</string> + <string name="start_after">Start after</string> + <string name="animate">Animate</string> + <string name="max_age_rating">Max age rating</string> + <string name="no_max">No max</string> + <string name="for_all_ages">For all ages</string> + <string name="up_to_age">Up to age %s</string> + <string name="screensaver">Screensaver</string> + <string name="screensaver_settings">Screensaver settings</string> + <string name="start_screensaver">Start screensaver</string> + <string name="duration">Duration</string> + <string name="photos">Photos</string> + <string name="include_types">Include types</string> + <string name="include_types_summary">What types of items to show images for</string> + <string name="content_scale_fit">Fit</string> + <string name="content_scale_crop">Crop</string> + <string name="content_scale_fill">Fill</string> + <string name="content_scale_fill_width">Fill width</string> + <string name="content_scale_fill_height">Fill height</string> + <string name="display_preset_default">Wholphin Default</string> + <string name="display_preset_compact">Wholphin Compact</string> + <string name="display_preset_series_thumb">Series Thumb images</string> + <string name="display_preset_episode_thumbnails">Episode Thumbnail images</string> + <string name="volume_lowest">Lowest</string> + <string name="volume_low">Low</string> + <string name="volume_medium">Medium</string> + <string name="volume_high">High</string> + <string name="volume_full">Full</string> <string-array name="theme_song_volume"> - <item>Disabled</item> - <item>Lowest</item> - <item>Low</item> - <item>Medium</item> - <item>High</item> - <item>Full</item> + <item>@string/disabled</item> + <item>@string/volume_lowest</item> + <item>@string/volume_low</item> + <item>@string/volume_medium</item> + <item>@string/volume_high</item> + <item>@string/volume_full</item> </string-array> + <string name="purple">Purple</string> + <string name="orange">Orange</string> + <string name="bold_blue">Bold Blue</string> + <string name="black">Black</string> <string-array name="app_theme_colors"> - <item>Purple</item> - <item>Blue</item> - <item>Green</item> - <item>Orange</item> - <item>Bold Blue</item> - <item>Black</item> + <item>@string/purple</item> + <item>@string/blue</item> + <item>@string/green</item> + <item>@string/orange</item> + <item>@string/bold_blue</item> + <item>@string/black</item> </string-array> + <string name="skip_ignore">Ignore</string> + <string name="skip_automatically">Skip automatically</string> + <string name="skip_ask">Ask to skip</string> <string-array name="skip_behaviors"> - <item>Ignore</item> - <item>Skip automatically</item> - <item>Ask to skip</item> + <item>@string/skip_ignore</item> + <item>@string/skip_automatically</item> + <item>@string/skip_ask</item> </string-array> <string-array name="content_scale"> - <item>Fit</item> - <item>None</item> - <item>Crop</item> - <item>Fill</item> - <item>Fill Width</item> - <item>Fill Height</item> + <item>@string/content_scale_fit</item> + <item>@string/none</item> + <item>@string/content_scale_crop</item> + <item>@string/content_scale_fill</item> + <item>@string/content_scale_fill_width</item> + <item>@string/content_scale_fill_height</item> </string-array> + <string name="ffmpeg_fallback">Only use FFmpeg if no built-in decoder exists</string> + <string name="ffmpeg_prefer">Prefer to use FFmpeg over built-in decoders</string> + <string name="ffmpeg_never">Never use FFmpeg decoders</string> <string-array name="ffmpeg_extension_options"> - <item>Only use FFmpeg if no built-in decoder exists</item> - <item>Prefer to use FFmpeg over built-in decoders</item> - <item>Never use FFmpeg decoders</item> + <item>@string/ffmpeg_fallback</item> + <item>@string/ffmpeg_prefer</item> + <item>@string/ffmpeg_never</item> </string-array> + <string name="next_up_playback_end">At the end of playback</string> + <string name="next_up_outro">During end credits/outro</string> <string-array name="show_next_up_when_options"> - <item>At the end of playback</item> - <item>During end credits/outro</item> + <item>@string/next_up_playback_end</item> + <item>@string/next_up_outro</item> </string-array> + <string name="white">White</string> + <string name="light_gray">Light gray</string> + <string name="dark_gray">Dark gray</string> + <string name="yellow">Yellow</string> + <string name="cyan">Cyan</string> + <string name="magenta">Magenta</string> <string-array name="font_colors"> - <item>White</item> - <item>Black</item> - <item>Light Gray</item> - <item>Dark Gray</item> - <item>Red</item> - <item>Yellow</item> - <item>Green</item> - <item>Cyan</item> - <item>Blue</item> - <item>Magenta</item> + <item>@string/white</item> + <item>@string/black</item> + <item>@string/light_gray</item> + <item>@string/dark_gray</item> + <item>@string/red</item> + <item>@string/yellow</item> + <item>@string/green</item> + <item>@string/cyan</item> + <item>@string/blue</item> + <item>@string/magenta</item> </string-array> + <string name="subtitle_edge_outline">Outline</string> + <string name="subtitle_edge_shadow">Shadow</string> <string-array name="subtitle_edge"> - <item>None</item> - <item>Outline</item> - <item>Shadow</item> + <item>@string/none</item> + <item>@string/subtitle_edge_outline</item> + <item>@string/subtitle_edge_shadow</item> </string-array> + <string name="background_style_wrap">Wrap</string> + <string name="background_style_boxed">Boxed</string> <string-array name="background_style"> - <item>None</item> - <item>Wrap</item> - <item>Boxed</item> + <item>@string/none</item> + <item>@string/background_style_wrap</item> + <item>@string/background_style_boxed</item> </string-array> + <string name="exoplayer" translatable="false">ExoPlayer</string> + <string name="mpv" translatable="false">MPV</string> + <string name="prefer_mpv">Prefer MPV</string> <string-array name="player_backend_options"> - <item>ExoPlayer</item> - <item>MPV</item> - <item>Prefer MPV</item> + <item>@string/exoplayer</item> + <item>@string/mpv</item> + <item>@string/prefer_mpv</item> </string-array> + <string name="player_backend_options_subtitles_prefer_mpv">Use ExoPlayer for HDR playback</string> <string-array name="player_backend_options_subtitles"> - <item /> - <item /> - <item>Use ExoPlayer for HDR playback</item> + <item /><!-- Intentionally blank --> + <item /><!-- Intentionally blank --> + <item>@string/player_backend_options_subtitles_prefer_mpv</item> </string-array> + <string name="aspect_ratios_poster">Poster (2:3)</string> + <string name="aspect_ratios_16_9">16:9</string> + <string name="aspect_ratios_4_3">4:3</string> + <string name="aspect_ratios_square">Square (1:1)</string> <string-array name="aspect_ratios"> - <item>Poster (2:3)</item> - <item>16:9</item> - <item>4:3</item> - <item>Square (1:1)</item> + <item>@string/aspect_ratios_poster</item> + <item>@string/aspect_ratios_16_9</item> + <item>@string/aspect_ratios_4_3</item> + <item>@string/aspect_ratios_square</item> </string-array> + <string name="image_type_primary">Primary</string> + <string name="image_type_thumb">Thumb</string> <string-array name="image_types"> - <item>Primary</item> - <item>Thumb</item> + <item>@string/image_type_primary</item> + <item>@string/image_type_thumb</item> <!-- <item>Banner</item>--> </string-array> + <string name="backdrop_style_dynamic">Image with dynamic color</string> + <string name="backdrop_style_image">Image only</string> + <string name="enter_url_api_key"><![CDATA[Enter URL & API Key]]></string> + <string name="api_key">API Key</string> + <string name="seerr_login">Login to Seerr server</string> + <string name="seerr_jellyfin_user">Jellyfin user</string> + <string name="seerr_local_user">Local user</string> + <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> + <string name="no_subtitles_found">No remote subtitles were found</string> + <string name="series_continueing">Present</string> + <string name="in_app_screensaver">Use in-app screensaver</string> + <string name="delete_item">Are you sure you want to delete this item?</string> + <string name="show_media_management">Show media management options</string> <string-array name="backdrop_style_options"> - <item>Image with dynamic color</item> - <item>Image only</item> - <item>None</item> + <item>@string/backdrop_style_dynamic</item> + <item>@string/backdrop_style_image</item> + <item>@string/none</item> </string-array> + <string-array name="screensaver_item_types"> + <item>@string/movies</item> + <item>@string/tv_shows</item> + <item>@string/photos</item> + </string-array> + + <string name="actor">Actor</string> + <string name="composer">Composer</string> + <string name="writer">Writer</string> + <string name="guest_star">Guest star</string> + <string name="producer">Producer</string> + <string name="conductor">Conductor</string> + <string name="lyricist">Lyricist</string> + <string name="arranger">Arranger</string> + <string name="engineer">Engineer</string> + <string name="mixer">Mixer</string> + <string name="creator">Creator</string> + <string name="artist">Artist</string> + <string name="search_for">Search %s</string> + <string name="select_all">Select all</string> + </resources> diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d619d042..4c21b699 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -711,6 +711,8 @@ components: type: boolean series4kEnabled: type: boolean + cacheImages: + type: boolean MovieResult: type: object required: @@ -1086,6 +1088,11 @@ components: type: array items: $ref: '#/components/schemas/Episode' + status: + type: integer + example: 0 + description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + readOnly: true TvDetails: type: object properties: @@ -1268,6 +1275,10 @@ components: type: string type: type: string + seasons: + type: array + items: + $ref: '#/components/schemas/Season' required: - id - status @@ -6135,16 +6146,10 @@ paths: type: integer example: 123 seasons: - # oneOf: - # - type: array - # items: - # type: integer - # minimum: 0 - # - type: string - # enum: [all] - # TODO - type: string - enum: [all] + type: array + items: + type: integer + minimum: 0 nullable: true is4k: type: boolean diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch new file mode 100644 index 00000000..ef5fb96d --- /dev/null +++ b/app/src/patches/play_store.patch @@ -0,0 +1,40 @@ +commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc +Author: Damontecres <damontecres@gmail.com> +Date: Sat Nov 22 13:00:55 2025 -0500 + + Setup for play store + +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +index 6d84299..12576af 100644 +--- a/app/src/main/AndroidManifest.xml ++++ b/app/src/main/AndroidManifest.xml +@@ -4,7 +4,6 @@ + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> +- <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> +@@ -17,7 +16,7 @@ + android:required="false" /> + <uses-feature + android:name="android.software.leanback" +- android:required="false" /> ++ android:required="true" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> +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 c7ac435..fa42fe1 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 +@@ -62,7 +62,7 @@ class UpdateChecker + + private val NOTE_REGEX = Regex("<!-- app-note:(.+) -->") + +- val ACTIVE = true ++ val ACTIVE = false + } + + suspend fun maybeShowUpdateToast( diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 7da1d63c..0b48a011 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.google.common.util.concurrent.ListenableFuture import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest { private val currentLiveData = MutableLiveData<CurrentUser?>() private val mockActivity = mockk<AppCompatActivity>(relaxed = true) private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockCache = mockk<SuggestionsCache>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) @@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest { SuggestionsSchedulerService( context = mockActivity, serverRepository = mockServerRepository, - cache = mockCache, workManager = mockWorkManager, - ).also { it.dispatcher = testDispatcher } + ).also { + it.dispatcher = testDispatcher + it.initialDelaySecondsProvider = { 60L } + } + + private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { + @Suppress("UNCHECKED_CAST") + val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() + every { future.get() } returns infos + every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future + } @Test fun schedules_periodic_work_when_user_present() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest { verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } + + @Test + fun does_not_schedule_if_already_scheduled_for_same_user() = + runTest { + val userId = UUID.randomUUID() + val workInfo = mockk<androidx.work.WorkInfo>() + every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED + every { workInfo.tags } returns setOf("user:$userId") + mockWorkInfos(listOf(workInfo)) + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt index 7d633d87..5679777e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -46,6 +46,7 @@ class SuggestionsWorkerTest { every { mockApi.userViewsApi } returns mockUserViewsApi every { mockApi.baseUrl } returns "http://localhost" every { mockApi.accessToken } returns "test-token" + coEvery { mockCache.get(any(), any(), any()) } returns null } @After diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt new file mode 100644 index 00000000..b0a8810f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString +import org.junit.Assert +import org.junit.Test + +class FormattingTests { + @Test + fun testResolutionStrings() { + Assert.assertEquals("4K", resolutionString(3840, 2160, false)) + Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) + Assert.assertEquals("720p", resolutionString(1280, 720, false)) + Assert.assertEquals("480i", resolutionString(640, 480, true)) + + Assert.assertEquals("576p", resolutionString(1024, 576, false)) + Assert.assertEquals("576p", resolutionString(960, 576, false)) + + // 21:9 + Assert.assertEquals("1080p", resolutionString(1920, 822, false)) + + Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() 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 index 7af1bfce..d7e4abfd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -15,7 +15,25 @@ class TestDisplayModeChoice { 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) + val HD720_60 = DisplayMode(6, 1280, 720, 60f) + val HD720_30 = DisplayMode(7, 1280, 720, 30f) + val HD720_24 = DisplayMode(8, 1280, 720, 24f) + + val ALL_MODES = + listOf( + UHD_24, + UHD_30, + UHD_60, + HD_24, + HD_30, + HD_60, + HD720_60, + HD720_30, + HD720_24, + ).sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) } @Test @@ -32,7 +50,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(3, result?.modeId) + Assert.assertEquals(UHD_60.modeId, result?.modeId) } @Test @@ -49,7 +67,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = true, ) - Assert.assertEquals(0, result?.modeId) + Assert.assertEquals(HD_60.modeId, result?.modeId) } @Test @@ -66,7 +84,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(4, result?.modeId) + Assert.assertEquals(UHD_30.modeId, result?.modeId) } @Test @@ -83,14 +101,14 @@ class TestDisplayModeChoice { refreshRateSwitch = false, resolutionSwitch = true, ) - Assert.assertEquals(1, result?.modeId) + Assert.assertEquals(HD_30.modeId, result?.modeId) } @Test fun testFraction() { val streamWidth = 1920 val streamHeight = 1080 - val streamRealFrameRate = 30f + val streamRealFrameRate = 29.970f val displayModes = listOf( @@ -104,7 +122,7 @@ class TestDisplayModeChoice { displayModes = displayModes, streamWidth = streamWidth, streamHeight = streamHeight, - targetFrameRate = 29.970f, + targetFrameRate = streamRealFrameRate, refreshRateSwitch = true, resolutionSwitch = false, ) @@ -119,6 +137,136 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(1, result2?.modeId) + Assert.assertEquals(HD_30.modeId, result2?.modeId) + } + + private fun test( + expected: DisplayMode, + streamWidth: Int, + streamHeight: Int, + streamRealFrameRate: Float, + ) { + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals( + "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", + expected.modeId, + result?.modeId, + ) + } + + @Test + fun `Test choose best resolution`() { + test(HD720_30, 1280, 720, 30f) + test(HD720_30, 1280, 548, 30f) + test(HD720_30, 640, 480, 30f) + test(HD720_30, 960, 720, 30f) + test(HD720_24, 960, 720, 24f) + } + + @Test + fun `Test 60fps for 24 without resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + UHD_60, + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(UHD_60.modeId, result?.modeId) + } + + @Test + fun `Test 60fps for 24 with resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_60.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 24f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_24, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_24.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution2`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 30f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_30, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_30.modeId, result?.modeId) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt index 76893f8c..c19ffb81 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -1,7 +1,9 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl import com.github.damontecres.wholphin.ui.setup.seerr.createUrls -import org.junit.Assert +import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl +import org.junit.Assert.assertEquals import org.junit.Test class TestSeerr { @@ -13,12 +15,12 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "https://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "https://jellyseerr.com/", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -29,10 +31,10 @@ class TestSeerr { val expected = listOf( - "https://jellyseerr.com/api/v1", - "https://jellyseerr.com:5055/api/v1", + "https://jellyseerr.com/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -43,10 +45,10 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com/api/v1", - "http://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com/", + "http://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) } @Test @@ -57,9 +59,115 @@ class TestSeerr { val expected = listOf( - "http://jellyseerr.com:5055/api/v1", - "https://jellyseerr.com:5055/api/v1", + "http://jellyseerr.com:5055/", + "https://jellyseerr.com:5055/", ) - Assert.assertEquals(expected, urls) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls5() { + val urls = + createUrls("10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:443/", + "https://10.0.0.2/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls6() { + val urls = + createUrls("10.0.0.2:8080") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:8080/", + "https://10.0.0.2:8080/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls7() { + val urls = + createUrls("http://10.0.0.2:80") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2/", + "http://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun testCreateUrls8() { + val urls = + createUrls("https://10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "https://10.0.0.2/", + "https://10.0.0.2:5055/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test createUrls for path`() { + val urls = + createUrls("https://jellyseerr.com/seerr/") + .map { it.toString() } + + val expected = + listOf( + "https://jellyseerr.com/seerr/", + "https://jellyseerr.com:5055/seerr/", + ) + assertEquals(expected, urls) + } + + @Test + fun `Test build api url`() { + var url = "https://jellyseerr.com/" + assertEquals("https://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "https://jellyseerr.com/path" + assertEquals("https://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:5055/" + assertEquals("http://jellyseerr.com:5055/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com:7878/path/" + assertEquals("http://jellyseerr.com:7878/path/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1" + assertEquals("http://jellyseerr.com/api/v1", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/api/v1/" + assertEquals("http://jellyseerr.com/api/v1/", createSeerrApiUrl(url)) + + url = "http://jellyseerr.com/path/api/v1" + assertEquals("http://jellyseerr.com/path/api/v1", createSeerrApiUrl(url)) + } + + @Test + fun `Test migration`() { + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1")) + assertEquals("https://10.0.0.2/", migrateSeerrUrl("https://10.0.0.2/api/v1")) + assertEquals("http://10.0.0.2:5055/", migrateSeerrUrl("http://10.0.0.2:5055/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/api/v1/")) + assertEquals("http://10.0.0.2/path/", migrateSeerrUrl("http://10.0.0.2/path/api/v1")) + assertEquals("http://10.0.0.2/api/v1/", migrateSeerrUrl("http://10.0.0.2/api/v1/api/v1")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2/")) + assertEquals("http://10.0.0.2/", migrateSeerrUrl("http://10.0.0.2")) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt new file mode 100644 index 00000000..ca8ef324 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -0,0 +1,56 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.services.getDownloadUrl +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import java.nio.file.Paths +import kotlin.io.path.readText + +class TestUpdateChecker { + lateinit var releaseJson: JsonObject + val assetsJson: JsonArray by lazy { releaseJson["assets"]!!.jsonArray } + + @Before + fun setup() { + val resource = javaClass.classLoader?.getResource("release_develop.json") + Assert.assertNotNull(resource) + val fileContents = Paths.get(resource!!.toURI()).readText() + releaseJson = Json.parseToJsonElement(fileContents).jsonObject + } + + @Test + fun `Release chooses release`() { + val url = getDownloadUrl(assetsJson, false, listOf()) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + } + + @Test + fun `Choose abi`() { + val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) + } + + @Test + fun `Choose unknown abi`() { + val url = getDownloadUrl(assetsJson, false, listOf("unknown")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + } + + @Test + fun `Debug chooses debug`() { + val url = getDownloadUrl(assetsJson, true, listOf()) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) + } + + @Test + fun `Choose debug abi`() { + val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt new file mode 100644 index 00000000..66178a4e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(TestModule.testDispatcher) + mockkStatic(Dispatchers::class) + + every { Dispatchers.IO } returns TestModule.testDispatcher + every { Dispatchers.Default } returns TestModule.testDispatcher + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.addServerState.value.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt new file mode 100644 index 00000000..39705dd3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,282 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao +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.PlaybackEffectDao +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.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.DatabaseModule +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser.value?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ + @Provides + @Singleton + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore<AppPreferences>, + @IoCoroutineScope scope: CoroutineScope, + ): RememberTabManager = mockk() + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, Dispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + + @Provides + @Singleton + fun userPreferencesDataStore( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore<AppPreferences> = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt new file mode 100644 index 00000000..d0bc8532 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,16 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.jellyfin.sdk.api.client.Response + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt new file mode 100644 index 00000000..6a6037f9 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeekAccelerationTest { + @Test + fun returnsOneWhenNotRepeating() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) + } + + @Test + fun unknownDurationDoesNotAccelerate() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) + } + + @Test + fun shortContentHasTwoTiers() { + val shortDurationMs = 20L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), + ) + } + + @Test + fun mediumContentEscalatesAcrossAllTiers() { + val mediumDurationMs = 60L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), + ) + assertEquals( + 3, + calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), + ) + assertEquals( + 4, + calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), + ) + } +} diff --git a/app/src/test/resources/release_develop.json b/app/src/test/resources/release_develop.json new file mode 100644 index 00000000..df48106f --- /dev/null +++ b/app/src/test/resources/release_develop.json @@ -0,0 +1,475 @@ +{ + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448", + "assets_url": "https://api.github.com/repos/damontecres/Wholphin/releases/297064448/assets", + "upload_url": "https://uploads.github.com/repos/damontecres/Wholphin/releases/297064448/assets{?name,label}", + "html_url": "https://github.com/damontecres/Wholphin/releases/tag/develop", + "id": 297064448, + "author": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "node_id": "RE_kwDOPzwRps4RtNgA", + "tag_name": "develop", + "target_commitish": "main", + "name": "v0.5.3-8-g627b89f1", + "draft": false, + "immutable": false, + "prerelease": true, + "created_at": "2026-03-14T20:47:08Z", + "updated_at": "2026-03-14T20:54:31Z", + "published_at": "2026-03-14T20:54:31Z", + "assets": [ + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937404", + "id": 373937404, + "node_id": "RA_kwDOPzwRps4WSdT8", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937407", + "id": 373937407, + "node_id": "RA_kwDOPzwRps4WSdT_", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:25Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937406", + "id": 373937406, + "node_id": "RA_kwDOPzwRps4WSdT-", + "name": "Wholphin-debug-0.5.3-8-g627b89f1-44.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937405", + "id": 373937405, + "node_id": "RA_kwDOPzwRps4WSdT9", + "name": "Wholphin-debug-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 65936692, + "digest": "sha256:ede68f14374dea27a92063d1aab765265cf5386c93533b01e7c666c4b8a6ea99", + "download_count": 0, + "created_at": "2026-03-14T20:54:23Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937408", + "id": 373937408, + "node_id": "RA_kwDOPzwRps4WSdUA", + "name": "Wholphin-debug-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 62726455, + "digest": "sha256:c2c6d0443af1302a8aa8a70426605e6ca67345b98a74b5b1d22b85e7de9012c2", + "download_count": 0, + "created_at": "2026-03-14T20:54:24Z", + "updated_at": "2026-03-14T20:54:26Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937422", + "id": 373937422, + "node_id": "RA_kwDOPzwRps4WSdUO", + "name": "Wholphin-debug.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 99552740, + "digest": "sha256:d0f5bbbe0c6f6bc31f681d70d60a5730dd0dbc83f28e66922fde27f4e60cda05", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937424", + "id": 373937424, + "node_id": "RA_kwDOPzwRps4WSdUQ", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937427", + "id": 373937427, + "node_id": "RA_kwDOPzwRps4WSdUT", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:26Z", + "updated_at": "2026-03-14T20:54:28Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937431", + "id": 373937431, + "node_id": "RA_kwDOPzwRps4WSdUX", + "name": "Wholphin-release-0.5.3-8-g627b89f1-44.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 0, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-0.5.3-8-g627b89f1-44.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937439", + "id": 373937439, + "node_id": "RA_kwDOPzwRps4WSdUf", + "name": "Wholphin-release-arm64-v8a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 56651710, + "digest": "sha256:bf010482a8b8626fb93bbb3285667b22a41c3548286d3319bda92c71eedba672", + "download_count": 1, + "created_at": "2026-03-14T20:54:27Z", + "updated_at": "2026-03-14T20:54:29Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937452", + "id": 373937452, + "node_id": "RA_kwDOPzwRps4WSdUs", + "name": "Wholphin-release-armeabi-v7a.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 53441542, + "digest": "sha256:5b8aee8f79fd5ae26dbd5de4a1b4d38b90bc6b35cd2e7fa5f2683909463da390", + "download_count": 0, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:30Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-armeabi-v7a.apk" + }, + { + "url": "https://api.github.com/repos/damontecres/Wholphin/releases/assets/373937453", + "id": 373937453, + "node_id": "RA_kwDOPzwRps4WSdUt", + "name": "Wholphin-release.apk", + "label": "", + "uploader": { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 90267831, + "digest": "sha256:bc10956a072a5a07c372a67ee8732171470529b468fd7d85a277a738604c34c5", + "download_count": 1, + "created_at": "2026-03-14T20:54:28Z", + "updated_at": "2026-03-14T20:54:31Z", + "browser_download_url": "https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk" + } + ], + "tarball_url": "https://api.github.com/repos/damontecres/Wholphin/tarball/develop", + "zipball_url": "https://api.github.com/repos/damontecres/Wholphin/zipball/develop", + "body": "### `main` development build\n\nThis pre-release tracks the development build of Wholphin from the `main` unstable branch.\n\nSee https://github.com/damontecres/Wholphin/releases/latest for the latest stable release.\n\nIf you want to update to this version in-app, change Settings->Advanced->Update URL to https://api.github.com/repos/damontecres/Wholphin/releases/tags/develop (replace `latest` with `tags/develop`).\n\n[See changes since `v0.5.3`](https://github.com/damontecres/Wholphin/compare/v0.5.3...develop)\n" +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bf860b..280d183e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,11 +8,11 @@ desugar_jdk_libs = "2.1.5" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" -kotlin = "2.3.10" +kotlin = "2.3.20" ksp = "2.3.6" -coreKtx = "1.17.0" +coreKtx = "1.18.0" appcompat = "1.7.1" -composeBom = "2026.02.00" +composeBom = "2026.03.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -20,21 +20,21 @@ okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" timber = "5.0.1" -tvFoundation = "1.0.0-alpha12" +tvFoundation = "1.0.0-beta01" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.4" -androidx-media3 = "1.9.2" -coil = "3.3.0" +activityCompose = "1.13.0" +androidx-media3 = "1.9.3" +coil = "3.4.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" -datastore = "1.2.0" +datastore = "1.2.1" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.33.5" -hilt = "2.59.1" +protobuf-javalite = "4.34.0" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -43,6 +43,7 @@ paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" +runner = "1.7.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy 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-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } 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" } @@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers 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" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } @@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55b..d997cfc6 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 23449a2b..dbc3ce4a 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index 23d15a93..0262dcbd 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index 5eed7ee8..e509b2dd 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,10 @@ goto fail :execute @rem Setup the command line -set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/scripts/mpv/get_dependencies.sh b/scripts/mpv/get_dependencies.sh index 7ca18de8..74bff6d6 100755 --- a/scripts/mpv/get_dependencies.sh +++ b/scripts/mpv/get_dependencies.sh @@ -30,7 +30,11 @@ clone "https://gitlab.freedesktop.org/freetype/freetype.git" "VER-2-14-1" freety clone "https://github.com/libass/libass" "0.17.4" libass -clone "https://github.com/haasn/libplacebo" "v7.351.0" libplacebo --recurse-submodules +rm -rf libplacebo +git clone "https://github.com/haasn/libplacebo" --single-branch -b "master" --recurse-submodules libplacebo +pushd libplacebo || exit +git checkout 1bd8d2d6a715bc870bdffa6759e62c419000dd51 +popd || exit clone "https://github.com/mpv-player/mpv" "v0.41.0" mpv