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: ""
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: ""
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: ""
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] - "
-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
-
+
## Features
@@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
### Customized home page

-
### Movie library browsing
-
+
### Movie page
-
+
### Series page
-
+
+
+### Genres in library
+
+
### Playlist
-
+
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()
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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" />
+
+
+
+
+
+
+
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,
+ 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
+
+ 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(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,
+ @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
- private val sharedPreferences = getServerSharedPreferences(context)
-
private var _current = EqualityMutableLiveData(null)
val current: LiveData = _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?,
+) = 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 {
valueToIndex = { it.number },
)
+ val ManageMedia =
+ AppSwitchPreference(
+ 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(
title = R.string.one_click_pause,
@@ -1031,6 +1043,12 @@ sealed interface AppPreference {
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
+
+ val ScreensaverSettings =
+ AppDestinationPreference(
+ 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(
@get:StringRes override val title: Int,
override val defaultValue: Boolean,
@@ -1295,8 +1333,6 @@ data class AppMultiChoicePreference(
override val getter: (prefs: Pref) -> List,
override val setter: (prefs: Pref, value: List) -> Pref,
@param:StringRes val summary: Int? = null,
- val toSharedPrefs: (T) -> String,
- val fromSharedPrefs: (String) -> T?,
) : AppPreference>
data class AppClickablePreference(
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(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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(
+ 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,
+ 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,
-) {
- 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,
+ ) {
+ 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,
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: ImageType,
+ imageTags: Map,
+ backdropTags: List,
parentThumbId: UUID? = null,
parentBackdropId: UUID? = null,
- backdropTags: List = 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(
+ replay = 1,
+ extraBufferCapacity = 0,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+
+ /**
+ * Listen for recently deleted items. Useful for ViewModels to react and refresh data
+ */
+ val deletedItemFlow: SharedFlow = _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()
val moreItems = mutableListOf()
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 =
@@ -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 = _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 =
+ 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 =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
+ val currentUserId: Flow = 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 =
api.searchApi
.discoverMoviesGet(page = page)
.results
- ?.map(::DiscoverItem)
+ ?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun trending(page: Int = 1): List =
api.searchApi
.discoverTrendingGet(page = page)
.results
- ?.map(::DiscoverItem)
+ ?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun upcomingMovies(page: Int = 1): List =
api.searchApi
.discoverMoviesUpcomingGet(page = page)
.results
- ?.map(::DiscoverItem)
+ ?.map { createDiscoverItem(it) }
.orEmpty()
suspend fun upcomingTv(page: Int = 1): List =
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 = mutableStateListOf(SetupDestination.Loading)
+ var backStack: MutableList = 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(
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 = 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,
) {
+ 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.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 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,
+ ) : 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.absent()) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(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,
@@ -512,6 +632,7 @@ fun chooseStream(
streams: List,
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>(8) {
+ expireAfterWriteDuration = 2.hours
+ }
+}
+
suspend fun getGenreImageMap(
api: ApiClient,
+ userId: UUID?,
scope: CoroutineScope,
imageUrlService: ImageUrlService,
genres: List,
parentId: UUID,
includeItemTypes: List?,
cardWidthPx: Int?,
+ useCache: Boolean = true,
): Map {
+ val key = GenreCacheKey(userId, parentId)
+ if (useCache) {
+ genreCache.getIfAvailable(key)?.let {
+ Timber.v("Got cached entry")
+ return it
+ }
+ }
val genreToUrl = ConcurrentHashMap()
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?,
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.absent()) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(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 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 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 =
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(null) }
var seasonDialog by remember { mutableStateOf(null) }
var moreDialog by remember { mutableStateOf(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,
+ seasons: List,
similar: List,
recommended: List,
trailers: List,
@@ -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(null)
val rating = MutableLiveData(null)
- val seasons = MutableLiveData>(listOf())
+ val seasons = MutableLiveData>(listOf())
val trailers = MutableLiveData>(listOf())
val people = MutableLiveData>(listOf())
val similar = MutableLiveData>()
@@ -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()
+ 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,
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,
+ onSubmit: (Set, Boolean) -> Unit,
+ request4kEnabled: Boolean,
+ modifier: Modifier,
+) {
+ val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
+ val selected =
+ remember {
+ mutableStateSetOf(
+ *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,
+ request4kEnabled: Boolean,
+ onSubmit: (Set, 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(null) }
var chooseVersion by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
+ var showDeleteDialog by remember { mutableStateOf(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(null)
val chosenStreams = MutableLiveData(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(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var showDeleteDialog by remember { mutableStateOf(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(null)
val discovered = MutableStateFlow>(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(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf(null) }
var showPlaylistDialog by remember { mutableStateOf>(Optional.absent()) }
- val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
+ var showDeleteDialog by remember { mutableStateOf(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,
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(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>(listOf())
val people = MutableLiveData>(listOf())
val similar = MutableLiveData>()
+ val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData(PeopleInItem())
val discovered = MutableStateFlow>(listOf())
+ val discoverSeries = MutableStateFlow(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(null) }
var showPlaylistDialog by remember { mutableStateOf(null) }
+ var showDeleteDialog by remember { mutableStateOf(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 = _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 BottomDialog(
gravity: Int,
currentChoice: BottomDialogItem? = 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 BottomDialog(
val interactionSource = remember { MutableInteractionSource() }
ListItem(
selected = choice == currentChoice,
+ enabled = choice.enabled,
onClick = {
onDismissRequest()
onSelectChoice(index, choice)
@@ -524,6 +531,7 @@ fun BottomDialog(
}
},
interactionSource = interactionSource,
+ modifier = Modifier.focusRequester(focusRequesters[index]),
)
}
}
@@ -539,6 +547,7 @@ data class BottomDialogItem(
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 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 ComposablePreference(
list
}
MultiChoicePreference(
- possibleValues = values as SortedSet,
+ 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 MultiChoicePreference(
title: String,
summary: String?,
- possibleValues: Set,
+ possibleValues: List,
selectedValues: Set,
onValueChange: (List) -> 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 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,
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>(listOf())
val serverStatus = MutableLiveData