Merge branch 'main' into develop/ac3-test

This commit is contained in:
Damontecres 2026-03-17 13:31:11 -04:00
commit a674867656
No known key found for this signature in database
166 changed files with 8463 additions and 1668 deletions

View file

@ -1,6 +1,4 @@
name: "Feature Request" title: "<title>"
description: Request a new feature
title: "[FEA] - <title>"
labels: [ labels: [
"enhancement" "enhancement"
] ]

View file

@ -1,6 +1,6 @@
name: "Bug Report" name: "Bug Report"
description: Report a bug description: Report a bug
title: "[BUG] - <title>" title: "<title>"
labels: [ labels: [
"bug" "bug"
] ]
@ -28,7 +28,7 @@ body:
attributes: attributes:
label: "App Version" label: "App Version"
description: What version of the app? description: What version of the app?
placeholder: "v0.3.0" placeholder: "v0.5.1"
validations: validations:
required: true required: true
- type: input - type: input
@ -36,7 +36,7 @@ body:
attributes: attributes:
label: "Server Version" label: "Server Version"
description: What version of the server? description: What version of the server?
placeholder: "10.11.3" placeholder: "10.11.6"
validations: validations:
required: true required: true
- type: input - type: input

View file

@ -1,6 +1,6 @@
name: "Playback Problem" name: "Playback Problem"
description: Report an issue with playback description: Report an issue with playback
title: "[BUG] - <title>" title: "<title>"
labels: [ labels: [
"bug", "playback" "bug", "playback"
] ]
@ -17,6 +17,7 @@ body:
attributes: attributes:
label: "Media info" label: "Media info"
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) 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: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -35,7 +36,7 @@ body:
attributes: attributes:
label: "App Version" label: "App Version"
description: What version of the app? description: What version of the app?
placeholder: "v0.3.0" placeholder: "v0.5.1"
validations: validations:
required: true required: true
- type: input - type: input
@ -43,7 +44,7 @@ body:
attributes: attributes:
label: "Server Version" label: "Server Version"
description: What version of the server? description: What version of the server?
placeholder: "10.11.3" placeholder: "10.11.6"
validations: validations:
required: true required: true
- type: input - type: input

View file

@ -1,15 +0,0 @@
name: "Question"
description: Ask a question
title: "[QST] - <title>"
labels: [
"question"
]
body:
- type: textarea
id: description
attributes:
label: "Question"
description: Please enter your question
placeholder: "How do I...?"
validations:
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -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

View file

@ -44,19 +44,18 @@ jobs:
id: buildapp id: buildapp
run: | run: |
./gradlew clean assembleDebug testDebugUnitTest --no-daemon ./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" 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: | run: |
tar -czf build.tgz ./app/. git apply app/src/patches/play_store.patch
- uses: actions/upload-artifact@v6 git diff
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

View file

@ -39,11 +39,24 @@ jobs:
SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" SIGNING_KEY: "${{ secrets.SIGNING_KEY }}"
run: | run: |
./gradlew clean assembleRelease --no-daemon ./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 - name: Verify signatures
run: | run: |
echo "Verify APK signatures" echo "Verify APK signatures"
find app/build/outputs/apk -name '*.apk' find app/build/outputs \( -name '*.apk' -or -name '*.aab' \)
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' \) -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 - name: Copy APK to shorter names
id: apks id: apks
run: | run: |
@ -59,11 +72,18 @@ jobs:
- name: Checksums - name: Checksums
run: | run: |
echo "SHA256 checksums:" 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 - name: Create GitHub release
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
gh release create "${{ env.TAG_NAME }}" \ 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" "app/build/outputs/apk/**/*.apk"

View file

@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
</p> </p>
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> ![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5)
## Features ## Features
@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
### Customized home page ### Customized home page
![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) ![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1)
### Movie library browsing ### Movie library browsing
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> ![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3)
### Movie page ### Movie page
<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> ![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6)
### Series page ### Series page
<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> ![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429)
### Genres in library
![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498)
### Playlist ### Playlist
<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> ![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc)

View file

@ -44,23 +44,67 @@ android {
targetSdk = 36 targetSdk = 36
versionCode = gitTags.trim().lines().size versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } 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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = false
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro", "proguard-rules.pro",
) )
isDebuggable = false 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 { debug {
isMinifyEnabled = false isMinifyEnabled = false
isDebuggable = true isDebuggable = true
applicationIdSuffix = ".debug" 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
@ -81,63 +125,6 @@ android {
room { room {
schemaDirectory("$projectDir/schemas") 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 { splits {
abi { abi {
@ -153,6 +140,12 @@ android {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
} }
} }
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
protobuf { protobuf {
@ -267,6 +260,7 @@ dependencies {
implementation(libs.androidx.room.testing) implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx) implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect) implementation(libs.androidx.media3.effect)
implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler) ksp(libs.androidx.hilt.compiler)
@ -305,4 +299,9 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing) testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric) 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)
} }

View file

@ -0,0 +1,75 @@
package com.github.damontecres.wholphin.test
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performKeyInput
import androidx.compose.ui.test.pressKey
import com.github.damontecres.wholphin.MainContent
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.ScreensaverState
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@HiltAndroidTest
class InstrumentedBasicUiTests {
@get:Rule(order = 0)
var hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeTestRule = createAndroidComposeRule<TestActivity>()
lateinit var screensaverService: ScreensaverService
@Before
fun setup() {
screensaverService = mockk(relaxed = true)
every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false))
}
@OptIn(ExperimentalTestApi::class)
@Test
fun myTest() {
// Start the app
composeTestRule.setContent {
WholphinTheme {
MainContent(
backStack = mutableListOf(SetupDestination.ServerList),
navigationManager = mockk(relaxed = true),
appPreferences = mockk(relaxed = true),
backdropService = mockk(relaxed = true),
screensaverService = screensaverService,
requestedDestination = Destination.Home(),
modifier = Modifier,
)
}
}
composeTestRule.onNodeWithText("Add Server").assertExists()
composeTestRule.onNodeWithTag("add_server").performKeyInput {
pressKey(Key.DirectionDown) // TODO
}
composeTestRule.onNodeWithTag("add_server").performClickEnter()
composeTestRule.onNodeWithText("Discovered Servers").assertExists()
}
}
@OptIn(ExperimentalTestApi::class)
fun SemanticsNodeInteraction.performClickEnter() =
performKeyInput {
pressKey(Key.DirectionCenter)
}

View file

@ -0,0 +1,7 @@
package com.github.damontecres.wholphin.test
import androidx.activity.ComponentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class TestActivity : ComponentActivity()

View file

@ -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)
}

View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:installLocation="auto">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<!-- Required for Android 11+ to query voice recognition availability -->
<queries>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Wholphin"
android:name=".WholphinApplication"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".test.TestActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
tools:node="remove" />
<!-- <service-->
<!-- android:name=".WholphinDreamService"-->
<!-- android:exported="true"-->
<!-- android:label="@string/app_name"-->
<!-- android:permission="android.permission.BIND_DREAM_SERVICE">-->
<!-- <intent-filter>-->
<!-- <action android:name="android.service.dreams.DreamService" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- </intent-filter>-->
<!-- </service>-->
</application>
</manifest>

View file

@ -68,6 +68,17 @@
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"
tools:node="remove" /> tools:node="remove" />
<service
android:name=".WholphinDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -19,28 +18,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope 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.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService 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.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService 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.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds 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.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination 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.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.DebugLogTree
@ -74,8 +60,13 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -102,9 +93,6 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var updateChecker: UpdateChecker lateinit var updateChecker: UpdateChecker
@Inject
lateinit var appUpgradeHandler: AppUpgradeHandler
@Inject @Inject
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
@ -123,6 +111,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
@Inject
lateinit var backdropService: BackdropService
// Note: unused but injected to ensure it is created // Note: unused but injected to ensure it is created
@Inject @Inject
lateinit var serverEventListener: ServerEventListener lateinit var serverEventListener: ServerEventListener
@ -131,6 +122,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var screensaverService: ScreensaverService
private var signInAuto = true private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -139,11 +133,7 @@ class MainActivity : AppCompatActivity() {
instance = this instance = this
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
lifecycle.addObserver(playbackLifecycleObserver) lifecycle.addObserver(playbackLifecycleObserver)
if (savedInstanceState == null) {
lifecycleScope.launchIO {
appUpgradeHandler.copySubfont(false)
}
}
viewModel.serverRepository.currentUser.observe(this) { user -> viewModel.serverRepository.currentUser.observe(this) { user ->
if (user?.hasPin == true) { if (user?.hasPin == true) {
window?.setFlags( window?.setFlags(
@ -154,6 +144,20 @@ class MainActivity : AppCompatActivity() {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) 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() viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) val appPreferences by userPreferencesDataStore.data.collectAsState(null)
@ -201,128 +205,43 @@ class MainActivity : AppCompatActivity() {
true, true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors, 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 { 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 = val requestedDestination =
remember(intent) { remember(intent) {
intent?.let(::extractDestination) intent?.let(::extractDestination) ?: Destination.Home()
} }
ApplicationContent( MainContent(
user = current.user, backStack = setupNavigationManager.backStack,
server = current.server,
startDestination =
requestedDestination
?: Destination.Home(),
navigationManager = navigationManager, navigationManager = navigationManager,
preferences = preferences, appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
requestedDestination = requestedDestination,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
}
}
}
}
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
if (screensaverService.state.value.show) {
screensaverService.stop(false)
screensaverService.pulse()
return true
} else { } else {
Box( screensaverService.pulse()
modifier = Modifier.size(200.dp), return super.dispatchKeyEvent(event)
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
}
},
)
}
}
}
}
} }
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
Timber.d("onResume") Timber.d("onResume")
lifecycleScope.launchIO { lifecycleScope.launchDefault {
appUpgradeHandler.run() screensaverService.pulse()
} }
} }
@ -335,6 +254,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Timber.d("onStop") Timber.d("onStop")
screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh() tvProviderSchedulerService.launchOneTimeRefresh()
} }
@ -346,6 +266,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
Timber.d("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) { override fun onSaveInstanceState(outState: Bundle) {
@ -440,10 +376,13 @@ class MainActivityViewModel
private val navigationManager: SetupNavigationManager, private val navigationManager: SetupNavigationManager,
private val deviceProfileService: DeviceProfileService, private val deviceProfileService: DeviceProfileService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val appUpgradeHandler: AppUpgradeHandler,
) : ViewModel() { ) : ViewModel() {
fun appStart() { fun appStart() {
viewModelScope.launchIO { viewModelScope.launchIO {
try { try {
appUpgradeHandler.run()
appUpgradeHandler.copySubfont(false)
val prefs = val prefs =
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
val userHasPin = serverRepository.currentUser.value?.hasPin == true val userHasPin = serverRepository.currentUser.value?.hasPin == true

View file

@ -0,0 +1,147 @@
package com.github.damontecres.wholphin
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.ui.components.AppScreensaver
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
@Composable
fun MainContent(
backStack: MutableList<SetupDestination>,
navigationManager: NavigationManager,
appPreferences: AppPreferences,
backdropService: BackdropService,
screensaverService: ScreensaverService,
requestedDestination: Destination,
modifier: Modifier = Modifier,
) {
Surface(
modifier =
modifier
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
LaunchedEffect(Unit) {
backdropService.clearBackdrop()
}
val current = key.current
val preferences =
remember(appPreferences) {
UserPreferences(appPreferences)
}
var showContent by remember {
mutableStateOf(true)
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (!appPreferences.signInAutomatically) {
showContent = false
}
}
if (showContent) {
ApplicationContent(
user = current.user,
server = current.server,
startDestination = requestedDestination,
navigationManager = navigationManager,
preferences = preferences,
modifier = Modifier.fillMaxSize(),
)
} else {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}
},
)
val screenSaverState by screensaverService.state.collectAsState()
if (screenSaverState.enabled || screenSaverState.enabledTemp) {
AnimatedVisibility(
screenSaverState.show,
Modifier.fillMaxSize(),
) {
AppScreensaver(appPreferences, Modifier.fillMaxSize())
}
}
}
}

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
import android.app.Application import android.app.Application
import android.os.Build import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composer import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.ExperimentalComposeRuntimeApi
@ -27,16 +26,6 @@ class WholphinApplication :
init { init {
instance = this instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.build(),
)
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} else { } else {
@ -64,6 +53,23 @@ class WholphinApplication :
override fun onCreate() { override fun onCreate() {
super.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) OkHttp.initialize(this)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java

View file

@ -0,0 +1,119 @@
package com.github.damontecres.wholphin
import android.service.dreams.DreamService
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.setViewTreeLifecycleOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
@AndroidEntryPoint
class WholphinDreamService :
DreamService(),
SavedStateRegistryOwner {
@Inject
lateinit var serverRepository: ServerRepository
@Inject
lateinit var screensaverService: ScreensaverService
@Inject
lateinit var preferencesDataStore: DataStore<AppPreferences>
private val lifecycleRegistry = LifecycleRegistry(this)
private val savedStateRegistryController =
SavedStateRegistryController.create(this).apply {
performAttach()
}
override val lifecycle: Lifecycle get() = lifecycleRegistry
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
override fun onCreate() {
super.onCreate()
savedStateRegistryController.performRestore(null)
lifecycleRegistry.currentState = Lifecycle.State.CREATED
lifecycleScope.launchDefault {
if (serverRepository.current.value == null) {
val prefs = preferencesDataStore.data.first()
serverRepository.restoreSession(prefs.currentServerId.toUUIDOrNull(), prefs.currentUserId.toUUIDOrNull())
}
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
val itemFlow = screensaverService.createItemFlow(lifecycleScope)
setContentView(
ComposeView(this).apply {
setViewTreeLifecycleOwner(this@WholphinDreamService)
setViewTreeSavedStateRegistryOwner(this@WholphinDreamService)
setContent {
val user by serverRepository.currentUser.observeAsState()
if (user != null) {
var prefs by remember { mutableStateOf<AppPreferences?>(null) }
LaunchedEffect(Unit) {
preferencesDataStore.data.collectLatest { prefs = it }
}
prefs?.let { prefs ->
WholphinTheme(appThemeColors = prefs.interfacePreferences.appThemeColors) {
ProvideLocalClock {
val screensaverPrefs = prefs.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null)
AppScreensaverContent(
currentItem = currentItem,
showClock = screensaverPrefs.showClock,
duration = screensaverPrefs.duration.milliseconds,
animate = screensaverPrefs.animate,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
}
},
)
}
override fun onDreamingStarted() {
super.onDreamingStarted()
lifecycleRegistry.currentState = Lifecycle.State.STARTED
}
override fun onDreamingStopped() {
super.onDreamingStopped()
lifecycleRegistry.currentState = Lifecycle.State.DESTROYED
}
}

View file

@ -47,7 +47,7 @@ interface SeerrServerDao {
suspend fun deleteUser( suspend fun deleteUser(
serverId: Int, serverId: Int,
jellyfinUserRowId: Int, jellyfinUserRowId: Int,
) ): Int
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)

View file

@ -9,10 +9,12 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences 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.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -41,9 +43,8 @@ class ServerRepository
val serverDao: JellyfinServerDao, val serverDao: JellyfinServerDao,
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _current = EqualityMutableLiveData<CurrentUser?>(null) private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: LiveData<CurrentUser?> = _current
@ -59,7 +60,7 @@ class ServerRepository
* The current user is removed * The current user is removed
*/ */
suspend fun addAndChangeServer(server: JellyfinServer) { suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server) serverDao.addOrUpdateServer(server)
} }
apiClient.update(baseUrl = server.url, accessToken = null) apiClient.update(baseUrl = server.url, accessToken = null)
@ -73,7 +74,7 @@ class ServerRepository
server: JellyfinServer, server: JellyfinServer,
user: JellyfinUser, user: JellyfinUser,
): CurrentUser? = ): CurrentUser? =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
if (server.id != user.serverId) { if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server") throw IllegalStateException("User is not part of the server")
} }
@ -107,7 +108,7 @@ class ServerRepository
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
sharedPreferences.edit(true) { getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
} }
@ -128,7 +129,7 @@ class ServerRepository
return null return null
} }
val serverAndUsers = val serverAndUsers =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.getServer(serverId) serverDao.getServer(serverId)
} }
if (serverAndUsers != null) { if (serverAndUsers != null) {
@ -151,7 +152,7 @@ class ServerRepository
} }
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server } serverId?.let { serverDao.getServer(serverId)?.server }
} }
@ -165,7 +166,7 @@ class ServerRepository
suspend fun changeUser( suspend fun changeUser(
serverUrl: String, serverUrl: String,
authenticationResult: AuthenticationResult, authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) { ) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken val accessToken = authenticationResult.accessToken
if (accessToken != null) { if (accessToken != null) {
val authedUser = authenticationResult.user val authedUser = authenticationResult.user
@ -215,7 +216,7 @@ class ServerRepository
} }
apiClient.update(accessToken = null) apiClient.update(accessToken = null)
} }
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id) serverDao.deleteUser(user.serverId, user.id)
} }
} }
@ -235,7 +236,7 @@ class ServerRepository
} }
apiClient.update(baseUrl = null, accessToken = null) apiClient.update(baseUrl = null, accessToken = null)
} }
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
serverDao.deleteServer(server.id) serverDao.deleteServer(server.id)
} }
} }
@ -254,7 +255,7 @@ class ServerRepository
suspend fun setUserPin( suspend fun setUserPin(
user: JellyfinUser, user: JellyfinUser,
pin: String?, pin: String?,
) = withContext(Dispatchers.IO) { ) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin) val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser) val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@ -267,7 +268,7 @@ class ServerRepository
} }
suspend fun authorizeQuickConnect(code: String): Boolean = suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) { withContext(ioDispatcher) {
val userId = currentUser.value?.id val userId = currentUser.value?.id
if (userId == null) { if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization") Timber.e("No user logged in for Quick Connect authorization")

View file

@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale import java.util.Locale
import java.util.UUID
import kotlin.time.Duration import kotlin.time.Duration
@Serializable @Serializable
@ -33,6 +34,7 @@ data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null, val imageUrlOverride: String? = null,
val destinationOverride: Destination? = null,
) : CardGridItem { ) : CardGridItem {
val id get() = data.id val id get() = data.id
@ -70,6 +72,8 @@ data class BaseItem(
} }
} }
val canDelete: Boolean get() = data.canDelete == true
@Transient @Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
@ -93,7 +97,11 @@ data class BaseItem(
data.indexNumber?.let { "E$it" } data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime), ?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText = 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.indexNumber?.let { "E$it" }
?: data.userData ?: data.userData
?.unplayedItemCount ?.unplayedItemCount
@ -166,6 +174,7 @@ data class BaseItem(
}?.toIntOrNull() }?.toIntOrNull()
fun destination(index: Int? = null): Destination { fun destination(index: Int? = null): Destination {
if (destinationOverride != null) return destinationOverride
val result = val result =
// Redirect episodes & seasons to their series if possible // Redirect episodes & seasons to their series if possible
when (type) { when (type) {
@ -234,3 +243,28 @@ data class BaseItemUi(
val episodeUnplayedCornerText: String?, val episodeUnplayedCornerText: String?,
val quickDetails: AnnotatedString, val quickDetails: AnnotatedString,
) )
fun createGenreDestination(
genreId: UUID,
genreName: String,
parentId: UUID,
parentName: String?,
includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection(
itemId = parentId,
filter =
CollectionFolderFilter(
nameOverride =
listOfNotNull(
genreName,
parentName,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genreId),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
)

View file

@ -31,6 +31,7 @@ data class Chapter(
) )
}, },
) )
}.orEmpty() }?.sortedBy { it.position }
.orEmpty()
} }
} }

View file

@ -2,25 +2,17 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.api.seerr.model.CreditCast import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response import com.github.damontecres.wholphin.api.seerr.model.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.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.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.toLocalDate
import com.github.damontecres.wholphin.util.LocalDateSerializer import com.github.damontecres.wholphin.util.LocalDateSerializer
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.UUIDSerializer import org.jellyfin.sdk.model.serializer.UUIDSerializer
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import java.time.LocalDate import java.time.LocalDate
import java.util.UUID 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. * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
*/ */
@Stable
@Serializable @Serializable
data class DiscoverItem( data class DiscoverItem(
val id: Int, val id: Int,
@ -83,17 +76,14 @@ data class DiscoverItem(
val overview: String?, val overview: String?,
val availability: SeerrAvailability, val availability: SeerrAvailability,
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?, @Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
val posterPath: String?, val posterUrl: String?,
val backdropPath: String?, val backDropUrl: String?,
val jellyfinItemId: UUID?, val jellyfinItemId: UUID?,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString() override val gridId: String get() = id.toString()
override val playable: Boolean = false override val playable: Boolean = false
override val sortName: String get() = title ?: "" 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 val destination: Destination
get() { get() {
val jfType = val jfType =
@ -112,103 +102,6 @@ data class DiscoverItem(
Destination.DiscoveredItem(this) 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( data class DiscoverRating(

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Stable 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@ -19,19 +23,21 @@ data class Person(
) { ) {
companion object { companion object {
fun fromDto( fun fromDto(
context: Context,
dto: BaseItemPerson, dto: BaseItemPerson,
api: ApiClient, api: ApiClient,
): Person = ): Person =
Person( Person(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name,
role = dto.role, role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false, favorite = false,
) )
fun fromDto( fun fromDto(
context: Context,
dto: BaseItemPerson, dto: BaseItemPerson,
favorite: Boolean, favorite: Boolean,
api: ApiClient, api: ApiClient,
@ -39,10 +45,51 @@ data class Person(
Person( Person(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name,
role = dto.role, role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite, 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
}

View file

@ -775,6 +775,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { it.number }, valueToIndex = { it.number },
) )
val ManageMedia =
AppSwitchPreference<AppPreferences>(
title = R.string.show_media_management,
defaultValue = false,
getter = { it.interfacePreferences.enableMediaManagement },
setter = { prefs, value ->
prefs.updateInterfacePreferences { enableMediaManagement = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val OneClickPause = val OneClickPause =
AppSwitchPreference<AppPreferences>( AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause, title = R.string.one_click_pause,
@ -1031,6 +1043,12 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val ScreensaverSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.screensaver_settings,
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
)
} }
} }
@ -1045,6 +1063,7 @@ val basicPreferences =
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle, AppPreference.SubtitleStyle,
AppPreference.ThemeColors, AppPreference.ThemeColors,
AppPreference.ScreensaverSettings,
), ),
), ),
PreferenceGroup( PreferenceGroup(
@ -1138,6 +1157,7 @@ val advancedPreferences =
preferences = preferences =
listOf( listOf(
AppPreference.ShowClock, AppPreference.ShowClock,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext, AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus, // AppPreference.NavDrawerSwitchOnFocus,
@ -1236,6 +1256,24 @@ val liveTvPreferences =
AppPreference.LiveTvColorCodePrograms, AppPreference.LiveTvColorCodePrograms,
) )
val screensaverPreferences =
listOf(
PreferenceGroup(
title = R.string.screensaver,
preferences =
listOf(
ScreensaverPreference.Enabled,
ScreensaverPreference.StartDelay,
ScreensaverPreference.Duration,
ScreensaverPreference.ShowClock,
ScreensaverPreference.Animate,
ScreensaverPreference.MaxAge,
ScreensaverPreference.ItemTypes,
ScreensaverPreference.Start,
),
),
)
data class AppSwitchPreference<Pref>( data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int, @get:StringRes override val title: Int,
override val defaultValue: Boolean, override val defaultValue: Boolean,
@ -1295,8 +1333,6 @@ data class AppMultiChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> List<T>, override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: Pref, value: List<T>) -> Pref, override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?,
) : AppPreference<Pref, List<T>> ) : AppPreference<Pref, List<T>>
data class AppClickablePreference<Pref>( data class AppClickablePreference<Pref>(

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.InvalidProtocolBufferException
import org.jellyfin.sdk.model.api.BaseItemKind
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import javax.inject.Inject import javax.inject.Inject
@ -120,6 +121,20 @@ class AppPreferencesSerializer
colorCodePrograms = colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue AppPreference.LiveTvColorCodePrograms.defaultValue
}.build() }.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() }.build()
advancedPreferences = advancedPreferences =
@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
photoPreferences = photoPreferences.toBuilder().apply(block).build() 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() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -0,0 +1,183 @@
package com.github.damontecres.wholphin.preferences
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import org.jellyfin.sdk.model.api.BaseItemKind
import kotlin.time.Duration.Companion.milliseconds
object ScreensaverPreference {
val Enabled =
AppSwitchPreference<AppPreferences>(
title = R.string.in_app_screensaver,
defaultValue = false,
getter = { it.interfacePreferences.screensaverPreference.enabled },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { enabled = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
const val DEFAULT_START_DELAY = 15 * 60_000L
private val startDelayValues =
listOf(
30_000L,
60_000L,
2 * 60_000L,
5 * 60_000L,
10 * 60_000L,
15 * 60_000L,
30 * 60_000L,
60 * 60_000L,
)
val StartDelay =
AppSliderPreference<AppPreferences>(
title = R.string.start_after,
defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(),
min = 0,
max = startDelayValues.size - 1L,
interval = 1,
getter = {
startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
startDelay = startDelayValues[value.toInt()]
}
},
summarizer = { value ->
if (value != null) {
val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY
v.milliseconds.toString()
} else {
null
}
},
)
const val DEFAULT_DURATION = 30_000L
private val durationValues =
listOf(
15_000L,
30_000L,
60_000L,
2 * 60_000L,
)
val Duration =
AppSliderPreference<AppPreferences>(
title = R.string.duration,
defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(),
min = 0,
max = durationValues.size - 1L,
interval = 1,
getter = {
durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
duration = durationValues[value.toInt()]
}
},
summarizer = { value ->
if (value != null) {
val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION
v.milliseconds.toString()
} else {
null
}
},
)
val ShowClock =
AppSwitchPreference<AppPreferences>(
title = R.string.show_clock,
defaultValue = AppPreference.ShowClock.defaultValue,
getter = { it.interfacePreferences.screensaverPreference.showClock },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { showClock = value }
},
summaryOn = R.string.yes,
summaryOff = R.string.no,
)
val Animate =
AppSwitchPreference<AppPreferences>(
title = R.string.animate,
defaultValue = true,
getter = { it.interfacePreferences.screensaverPreference.animate },
setter = { prefs, value ->
prefs.updateScreensaverPreferences { animate = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
const val DEFAULT_MAX_AGE = 16
private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1)
val MaxAge =
AppSliderPreference<AppPreferences>(
title = R.string.max_age_rating,
defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(),
min = 0,
max = maxAgeValues.size - 1L,
interval = 1,
getter = {
it.interfacePreferences.screensaverPreference.maxAgeFilter
.takeIf { it >= 0 }
?.let { maxAgeValues.indexOf(it).toLong() }
?: maxAgeValues.lastIndex.toLong()
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
maxAgeFilter = maxAgeValues[value.toInt()]
}
},
summarizer = { value ->
when (value) {
null -> {
null
}
maxAgeValues.lastIndex.toLong() -> {
WholphinApplication.instance.getString(R.string.no_max)
}
0L -> {
WholphinApplication.instance.getString(R.string.for_all_ages)
}
else -> {
WholphinApplication.instance.getString(
R.string.up_to_age,
maxAgeValues[value.toInt()].toString(),
)
}
}
},
)
val ItemTypes =
AppMultiChoicePreference<AppPreferences, BaseItemKind>(
title = R.string.include_types,
summary = R.string.include_types_summary,
defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES),
allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO),
displayValues = R.array.screensaver_item_types,
getter = {
it.interfacePreferences.screensaverPreference.itemTypesList.map { type ->
BaseItemKind.fromName(type)
}
},
setter = { prefs, value ->
prefs.updateScreensaverPreferences {
clearItemTypes()
addAllItemTypes(value.map { it.serialName })
}
},
)
val Start =
AppClickablePreference<AppPreferences>(
title = R.string.start_screensaver,
)
}

View file

@ -6,8 +6,10 @@ import androidx.core.content.edit
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication 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.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences 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.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides 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.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings 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 com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -32,9 +37,14 @@ class AppUpgradeHandler
constructor( constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
private val appPreferences: DataStore<AppPreferences>, private val appPreferences: DataStore<AppPreferences>,
private val seerrServerDao: SeerrServerDao,
) { ) {
suspend fun run() { 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 prefs = PreferenceManager.getDefaultSharedPreferences(context)
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null) val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1) val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
@ -59,14 +69,16 @@ class AppUpgradeHandler
try { try {
copySubfont(true) copySubfont(true)
upgradeApp( upgradeApp(
context, Version.fromString(previousVersion ?: "0.0.0"),
Version.Companion.fromString(previousVersion ?: "0.0.0"), Version.fromString(newVersion),
Version.Companion.fromString(newVersion),
appPreferences, appPreferences,
) )
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Exception during app upgrade") Timber.e(ex, "Exception during app upgrade")
} }
Timber.i("App upgrade complete")
} else {
Timber.d("No app update needed")
} }
} }
@ -97,14 +109,12 @@ class AppUpgradeHandler
const val VERSION_NAME_CURRENT_KEY = "version.current.name" const val VERSION_NAME_CURRENT_KEY = "version.current.name"
const val VERSION_CODE_CURRENT_KEY = "version.current.code" const val VERSION_CODE_CURRENT_KEY = "version.current.code"
} }
}
suspend fun upgradeApp( suspend fun upgradeApp(
context: Context,
previous: Version, previous: Version,
current: Version, current: Version,
appPreferences: DataStore<AppPreferences>, appPreferences: DataStore<AppPreferences>,
) { ) {
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) { if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
appPreferences.updateData { appPreferences.updateData {
it.updatePlaybackOverrides { it.updatePlaybackOverrides {
@ -223,7 +233,8 @@ suspend fun upgradeApp(
subtitlesPreferences subtitlesPreferences
.toBuilder() .toBuilder()
.apply { .apply {
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() imageSubtitleOpacity =
SubtitleSettings.ImageOpacity.defaultValue.toInt()
}.build() }.build()
// Copy current subtitle prefs as HDR ones // Copy current subtitle prefs as HDR ones
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
@ -246,4 +257,28 @@ suspend fun upgradeApp(
} }
} }
} }
}
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)),
)
}
}
}
}

View file

@ -52,7 +52,7 @@ class BackdropService
if (item.type == BaseItemKind.GENRE) { if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride item.imageUrlOverride
} else { } else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} }
submit(item.id.toString(), imageUrl) submit(item.id.toString(), imageUrl)
} }

View file

@ -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.HomePageSettings
import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.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.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.Library
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions 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.toBaseItems
import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler
@ -567,6 +569,7 @@ class HomeSettingsService
userDto: UserDto, userDto: UserDto,
libraries: List<Library>, libraries: List<Library>,
limit: Int = prefs.maxItemsPerRow, limit: Int = prefs.maxItemsPerRow,
isRefresh: Boolean,
): HomeRowLoadingState = ): HomeRowLoadingState =
when (row) { when (row) {
is HomeRowConfig.ContinueWatching -> { is HomeRowConfig.ContinueWatching -> {
@ -647,25 +650,42 @@ class HomeSettingsService
val genreImages = val genreImages =
getGenreImageMap( getGenreImageMap(
api = api, api = api,
userId = serverRepository.currentUser.value?.id,
scope = scope, scope = scope,
imageUrlService = imageUrlService, imageUrlService = imageUrlService,
genres = genreIds, genres = genreIds,
parentId = row.parentId, parentId = row.parentId,
includeItemTypes = null, includeItemTypes = null,
cardWidthPx = null, cardWidthPx = null,
useCache = isRefresh,
) )
val genres = val library =
items.map {
BaseItem(it, false, genreImages[it.id])
}
val name =
libraries libraries
.firstOrNull { it.itemId == row.parentId } .firstOrNull { it.itemId == row.parentId }
?.name
val title = 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) ?: 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( Success(
title, title,

View file

@ -28,18 +28,16 @@ class ImageUrlService
itemType: BaseItemKind, itemType: BaseItemKind,
seriesId: UUID?, seriesId: UUID?,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType, imageType: ImageType,
imageTags: Map<ImageType, String?>,
backdropTags: List<String>,
parentThumbId: UUID? = null, parentThumbId: UUID? = null,
parentBackdropId: UUID? = null, parentBackdropId: UUID? = null,
backdropTags: List<String> = emptyList(),
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
): String? = ): String? =
when (imageType) { when (imageType) {
ImageType.BACKDROP, ImageType.LOGO -> {
ImageType.LOGO,
-> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl( getItemImageUrl(
itemId = seriesId, 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 -> { ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null && if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)

View file

@ -0,0 +1,110 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.showToast
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MediaManagementService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
private val serverRepository: ServerRepository,
private val userPreferencesService: UserPreferencesService,
) {
private val _deletedItemFlow =
MutableSharedFlow<DeletedItem>(
replay = 1,
extraBufferCapacity = 0,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/**
* Listen for recently deleted items. Useful for ViewModels to react and refresh data
*/
val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow
suspend fun canDelete(item: BaseItem): Boolean {
val appPreferences = userPreferencesService.getCurrent().appPreferences
return canDelete(item, appPreferences)
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean {
Timber.v("canDelete %s: %s", item.id, item.canDelete)
val enabled = appPreferences.interfacePreferences.enableMediaManagement
return enabled &&
item.canDelete &&
if (item.type == BaseItemKind.RECORDING) {
serverRepository.currentUserDto.value
?.policy
?.enableLiveTvManagement == true
} else {
true
}
}
suspend fun deleteItem(item: BaseItem): DeleteResult {
try {
Timber.i("Deleting %s", item.id)
// TODO enable
api.libraryApi.deleteItem(item.id)
_deletedItemFlow.emit(DeletedItem(item))
return DeleteResult.Success
} catch (ex: Exception) {
Timber.e(ex, "Error deleting %s", item.id)
return DeleteResult.Error(ex)
}
}
}
data class DeletedItem(
val item: BaseItem,
)
sealed interface DeleteResult {
data object Success : DeleteResult
data class Error(
val ex: Exception,
) : DeleteResult
}
fun ViewModel.deleteItem(
context: Context,
mediaManagementService: MediaManagementService,
item: BaseItem,
onSuccess: () -> Unit = {},
) = viewModelScope.launchIO {
when (val r = mediaManagementService.deleteItem(item)) {
is DeleteResult.Error -> {
showToast(
context,
"Error deleting item: ${r.ex.localizedMessage}",
)
}
DeleteResult.Success -> {
showToast(context, "Deleted")
onSuccess.invoke()
}
}
}

View file

@ -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.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.supportedCollectionTypes import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -60,7 +64,11 @@ class NavDrawerService
if (user != null && userDto != null && user.id == userDto.id) { if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto) updateNavDrawer(user, userDto)
} }
}.catch { ex ->
Timber.e(ex, "Error updating nav drawer")
showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
seerrServerRepository.active seerrServerRepository.active
.onEach { discoverActive -> .onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) } _state.update { it.copy(discoverEnabled = discoverActive) }
@ -143,7 +151,9 @@ class NavDrawerService
val allItems = builtins + libraries val allItems = builtins + libraries
val navDrawerPins = val navDrawerPins =
withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
val items = mutableListOf<NavDrawerItem>() val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>() val moreItems = mutableListOf<NavDrawerItem>()

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services 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.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject import javax.inject.Inject
@ -12,6 +14,7 @@ import javax.inject.Singleton
class PeopleFavorites class PeopleFavorites
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
) { ) {
suspend fun getPeopleFor(item: BaseItem): List<Person> = suspend fun getPeopleFor(item: BaseItem): List<Person> =
@ -27,7 +30,14 @@ class PeopleFavorites
val people = val people =
item.data.people item.data.people
?.letNotEmpty { 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() }.orEmpty()
people people
} }

View file

@ -158,33 +158,58 @@ class RefreshRateService
if (refreshRateSwitch) { if (refreshRateSwitch) {
displayModes displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
.filter { .filter { frameRateMatches(it.refreshRateRounded, streamRate) }
it.refreshRateRounded % streamRate == 0 || // Exact multiple
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
}
} else { } else {
displayModes displayModes
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
} }
// Timber.v("display modes candidates: %s", candidates.joinToString("\n")) // Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
return if (!resolutionSwitch) { 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 { } else {
// Exact resolution & frame rate
candidates.firstOrNull { candidates.firstOrNull {
it.physicalWidth == streamWidth && it.physicalWidth == streamWidth &&
it.physicalHeight == streamHeight && it.physicalHeight == streamHeight &&
it.refreshRateRounded == streamRate it.refreshRateRounded == streamRate
} }
?: candidates.firstOrNull { // Next highest resolution, exact frame rate
it.physicalWidth == streamWidth && ?: candidates.lastOrNull {
it.physicalWidth >= streamWidth &&
it.physicalHeight >= streamHeight && it.physicalHeight >= streamHeight &&
it.refreshRateRounded == streamRate 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 ?: candidates
.filter { it.refreshRateRounded == streamRate } .filter { it.refreshRateRounded == streamRate }
.maxByOrNull { it.physicalWidth * it.physicalHeight } .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
}
} }
} }

View file

@ -0,0 +1,222 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import coil3.imageLoader
import coil3.request.ImageRequest
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
import com.github.damontecres.wholphin.ui.components.CurrentItem
import com.github.damontecres.wholphin.ui.formatDate
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.cancellable
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
@Singleton
class ScreensaverService
@Inject
constructor(
@param:ApplicationContext private val context: Context,
@param:DefaultCoroutineScope private val scope: CoroutineScope,
private val api: ApiClient,
private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService,
) {
private val _state = MutableStateFlow(ScreensaverState(false, false, false, false))
val state: StateFlow<ScreensaverState> = _state
val keepScreenOn = MutableStateFlow(false)
private var waitJob: Job? = null
init {
userPreferencesService.flow
.onEach { prefs ->
_state.update {
val enabled =
prefs.appPreferences.interfacePreferences.screensaverPreference.enabled
keepScreenOnInternal(enabled)
ScreensaverState(enabled, false, false, false)
}
}.launchIn(scope)
}
fun pulse() {
waitJob?.cancel()
if (_state.value.enabled) {
// Timber.v("pulse")
_state.update {
if (!it.active) {
it.copy(active = false)
} else {
it
}
}
if (!_state.value.paused) {
waitJob =
scope.launch(ExceptionHandler()) {
val startDelay =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds
delay(startDelay)
_state.update {
it.copy(active = true)
}
}
}
}
}
fun start() {
_state.update {
it.copy(
enabledTemp = true,
active = true,
)
}
}
fun stop(cancelJob: Boolean) {
_state.update {
it.copy(
enabledTemp = false,
active = false,
)
}
if (cancelJob) waitJob?.cancel()
}
fun keepScreenOn(keep: Boolean) {
scope.launchDefault {
val screensaverEnabled = _state.value.enabled
Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled)
if (screensaverEnabled) {
// Page is requesting to keep screen on, so we don't wait to show the screensaver
_state.update {
it.copy(active = false, paused = keep)
}
if (!keep) {
pulse()
}
} else {
keepScreenOnInternal(keep)
}
}
}
private fun keepScreenOnInternal(keep: Boolean) {
keepScreenOn.update { keep }
}
fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> =
flow {
val prefs =
userPreferencesService.flow
.first()
.appPreferences
.interfacePreferences.screensaverPreference
val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 }
val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) }
val request =
GetItemsRequest(
recursive = true,
includeItemTypes = itemTypes,
imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP),
sortBy = listOf(ItemSortBy.RANDOM),
maxOfficialRating = maxAge?.toString(),
hasParentalRating = maxAge?.let { true },
)
val pager =
ApiRequestPager(api, request, GetItemsRequestHandler, scope).init()
Timber.v("Got %s items", pager.size)
var index = 0
if (pager.isEmpty()) {
emit(null)
} else {
val duration =
userPreferencesService
.getCurrent()
.appPreferences
.interfacePreferences.screensaverPreference.duration.milliseconds
while (true) {
try {
val item = pager.getBlocking(index)
Timber.v("Next index=%s, item=%s", index, item?.id)
if (item != null) {
val backdropUrl =
if (item.type == BaseItemKind.PHOTO) {
api.libraryApi.getDownloadUrl(item.id)
} else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
}
val title =
if (item.type == BaseItemKind.PHOTO) {
item.data.premiereDate?.let {
formatDate(it.toLocalDate())
}
} else {
item.title
}
val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO)
if (backdropUrl != null) {
context.imageLoader
.enqueue(
ImageRequest
.Builder(context)
.data(backdropUrl)
.build(),
).job
.await()
emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
delay(duration)
}
}
} catch (_: CancellationException) {
break
} catch (ex: Exception) {
Timber.e(ex, "Error fetching next item")
delay(duration)
}
index++
if (index > pager.lastIndex) index = 0
}
}
}.flowOn(Dispatchers.Default).cancellable()
}
data class ScreensaverState(
val enabled: Boolean,
val enabledTemp: Boolean,
val active: Boolean,
val paused: Boolean,
) {
val show get() = (enabled || enabledTemp) && active && !paused
}

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
/** /**
@ -24,6 +25,6 @@ class SeerrApi(
baseUrl: String, baseUrl: String,
apiKey: String?, apiKey: String?,
) { ) {
api = SeerrApiClient(baseUrl, apiKey, okHttpClient) api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient)
} }
} }

View file

@ -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.api.seerr.model.User
import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.SeerrServerDao
import com.github.damontecres.wholphin.data.ServerRepository 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.SeerrAuthMethod
import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.SeerrServer 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.data.model.hasPermission
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.qualifiers.ActivityContext import dagger.hilt.android.qualifiers.ActivityContext
import dagger.hilt.android.scopes.ActivityScoped import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.supervisorScope
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.ImageType
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -54,6 +59,7 @@ class SeerrServerRepository
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> = val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/** /**
* Whether Seerr integration is currently active of not * Whether Seerr integration is currently active of not
@ -67,10 +73,11 @@ class SeerrServerRepository
} }
fun error( fun error(
serverUrl: String, server: SeerrServer,
user: SeerrUser,
exception: Exception, exception: Exception,
) { ) {
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } _connection.update { SeerrConnectionStatus.Error(server, user, exception) }
seerrApi.update("", null) seerrApi.update("", null)
} }
@ -158,11 +165,11 @@ class SeerrServerRepository
val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY } val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
val api = val api =
SeerrApiClient( SeerrApiClient(
url, createSeerrApiUrl(url),
apiKey, apiKey,
okHttpClient okHttpClient
.newBuilder() .newBuilder()
.connectTimeout(5.seconds) .connectTimeout(2.seconds)
.readTimeout(6.seconds) .readTimeout(6.seconds)
.build(), .build(),
) )
@ -170,10 +177,16 @@ class SeerrServerRepository
return LoadingState.Success return LoadingState.Success
} }
suspend fun removeServer() { suspend fun removeServerForCurrentUser(): Boolean {
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return val user =
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) 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() clear()
return rows > 0
} }
} }
@ -186,7 +199,8 @@ sealed interface SeerrConnectionStatus {
data object NotConfigured : SeerrConnectionStatus data object NotConfigured : SeerrConnectionStatus
data class Error( data class Error(
val serverUrl: String, val server: SeerrServer,
val user: SeerrUser,
val ex: Exception, val ex: Exception,
) : SeerrConnectionStatus ) : SeerrConnectionStatus
@ -266,6 +280,14 @@ class UserSwitchListener
seerrServerRepository.clear() seerrServerRepository.clear()
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
if (user != null) { if (user != null) {
switchUser(user)
}
}
}
}
private suspend fun switchUser(user: JellyfinUser) =
supervisorScope {
// Check for home settings // Check for home settings
launchIO { launchIO {
homeSettingsService.loadCurrentSettings(user.id) homeSettingsService.loadCurrentSettings(user.id)
@ -293,20 +315,42 @@ class UserSwitchListener
} else { } else {
seerrApi.api.usersApi.authMeGet() seerrApi.api.usersApi.authMeGet()
} }
seerrServerRepository.set(server, seerrUser, userConfig) seerrServerRepository.set(
server,
seerrUser,
userConfig,
)
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.w( Timber.w(
ex, ex,
"Error logging into %s", "Error logging into %s",
server.url, server.url,
) )
seerrServerRepository.error(server.url, ex) 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"
}

View file

@ -1,11 +1,25 @@
package com.github.damontecres.wholphin.services package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient 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.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.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem 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.first
import kotlinx.coroutines.flow.firstOrNull
import org.jellyfin.sdk.model.api.BaseItemKind 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -22,6 +36,7 @@ class SeerrService
constructor( constructor(
private val seerApi: SeerrApi, private val seerApi: SeerrApi,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,
private val imageUrlService: ImageUrlService,
) { ) {
val api: SeerrApiClient get() = seerApi.api val api: SeerrApiClient get() = seerApi.api
@ -40,35 +55,35 @@ class SeerrService
api.searchApi api.searchApi
.discoverTvGet(page = page) .discoverTvGet(page = page)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> = suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi api.searchApi
.discoverMoviesGet(page = page) .discoverMoviesGet(page = page)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
suspend fun trending(page: Int = 1): List<DiscoverItem> = suspend fun trending(page: Int = 1): List<DiscoverItem> =
api.searchApi api.searchApi
.discoverTrendingGet(page = page) .discoverTrendingGet(page = page)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> = suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
api.searchApi api.searchApi
.discoverMoviesUpcomingGet(page = page) .discoverMoviesUpcomingGet(page = page)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> = suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
api.searchApi api.searchApi
.discoverTvUpcomingGet(page = page) .discoverTvUpcomingGet(page = page)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
/** /**
@ -89,14 +104,14 @@ class SeerrService
api.moviesApi api.moviesApi
.movieMovieIdSimilarGet(movieId = it) .movieMovieIdSimilarGet(movieId = it)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
} }
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
api.tvApi api.tvApi
.tvTvIdSimilarGet(tvId = it) .tvTvIdSimilarGet(tvId = it)
.results .results
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
} }
BaseItemKind.PERSON -> { BaseItemKind.PERSON -> {
@ -106,12 +121,12 @@ class SeerrService
val cast = val cast =
credits.cast credits.cast
?.take(25) ?.take(25)
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
val crew = val crew =
credits.crew credits.crew
?.take(25) ?.take(25)
?.map(::DiscoverItem) ?.map { createDiscoverItem(it) }
.orEmpty() .orEmpty()
cast + crew cast + crew
} }
@ -125,4 +140,186 @@ class SeerrService
} else { } else {
null 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(),
)
} }

View file

@ -17,7 +17,7 @@ import javax.inject.Singleton
class SetupNavigationManager class SetupNavigationManager
@Inject @Inject
constructor() { constructor() {
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
/** /**
* Go to the specified [SetupDestination] * Go to the specified [SetupDestination]

View file

@ -8,6 +8,7 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -31,7 +34,6 @@ class SuggestionsSchedulerService
constructor( constructor(
@param:ActivityContext private val context: Context, @param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val cache: SuggestionsCache,
private val workManager: WorkManager, private val workManager: WorkManager,
) { ) {
private val activity = private val activity =
@ -42,6 +44,7 @@ class SuggestionsSchedulerService
// Exposed for testing // Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
init { init {
serverRepository.current.observe(activity) { user -> serverRepository.current.observe(activity) { user ->
@ -60,6 +63,28 @@ class SuggestionsSchedulerService
userId: UUID, userId: UUID,
serverId: 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 = val constraints =
Constraints Constraints
.Builder() .Builder()
@ -75,12 +100,18 @@ class SuggestionsSchedulerService
val periodicWorkRequestBuilder = val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<SuggestionsWorker>( PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(), repeatInterval = 12.hours.toJavaDuration(),
flexTimeInterval = 1.hours.toJavaDuration(),
).setConstraints(constraints) ).setConstraints(constraints)
.setBackoffCriteria( .setBackoffCriteria(
BackoffPolicy.EXPONENTIAL, BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(), 15.minutes.toJavaDuration(),
).setInputData(inputData) ).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( workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME, uniqueWorkName = SuggestionsWorker.WORK_NAME,

View file

@ -99,11 +99,17 @@ class SuggestionsWorker
itemsPerRow, itemsPerRow,
) )
ensureActive() 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( cache.put(
userId, userId,
view.id, view.id,
itemKind, itemKind,
suggestions.map { it.id }, newIds,
) )
}.onFailure { e -> }.onFailure { e ->
Timber.e( Timber.e(
@ -242,7 +248,7 @@ class SuggestionsWorker
GetItemsRequest( GetItemsRequest(
parentId = parentId, parentId = parentId,
userId = userId, userId = userId,
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, fields = extraFields,
includeItemTypes = listOf(itemKind), includeItemTypes = listOf(itemKind),
genreIds = genreIds, genreIds = genreIds,
recursive = true, recursive = true,
@ -252,7 +258,7 @@ class SuggestionsWorker
sortOrder = sortOrder?.let { listOf(it) }, sortOrder = sortOrder?.let { listOf(it) },
limit = limit, limit = limit,
enableTotalRecordCount = false, enableTotalRecordCount = false,
imageTypeLimit = 1, imageTypeLimit = 0,
) )
return GetItemsRequestHandler return GetItemsRequestHandler
.execute(api, request) .execute(api, request)

View file

@ -14,7 +14,9 @@ import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.BuildConfig
import com.github.damontecres.wholphin.R 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.services.hilt.StandardOkHttpClient
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
@ -51,9 +53,8 @@ class UpdateChecker
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
) { ) {
companion object { companion object {
// TODO apk names const val ASSET_NAME = "Wholphin"
private const val ASSET_NAME = "Wholphin" const val APK_NAME = "$ASSET_NAME.apk"
private const val APK_NAME = "$ASSET_NAME.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
@ -118,11 +119,6 @@ class UpdateChecker
suspend fun getLatestRelease(updateUrl: String): Release? { suspend fun getLatestRelease(updateUrl: String): Release? {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val preferRelease =
PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean("updatePreferRelease", true)
val request = val request =
Request Request
.Builder() .Builder()
@ -140,7 +136,7 @@ class UpdateChecker
val downloadUrl = val downloadUrl =
result.jsonObject["assets"] result.jsonObject["assets"]
?.jsonArray ?.jsonArray
?.let { assets -> getDownloadUrl(assets, preferRelease) } ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
Timber.v("version=$version, downloadUrl=$downloadUrl") Timber.v("version=$version, downloadUrl=$downloadUrl")
if (version != null) { if (version != null) {
val notes = 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( suspend fun installRelease(
release: Release, release: Release,
callback: DownloadCallback, callback: DownloadCallback,
@ -387,3 +354,33 @@ suspend fun copyTo(
} }
return bytesCopied return bytesCopied
} }
fun getDownloadUrl(
assets: JsonArray,
debug: Boolean,
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
): String? {
val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
val releaseSuffix = if (debug) "-debug" else "-release"
val preferredNames =
buildList {
add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk")
add("$ASSET_NAME$releaseSuffix.apk")
if (!debug) add("$ASSET_NAME.apk")
}
var preferredAsset: JsonObject? = null
outer@ for (name in preferredNames) {
for (asset in assets) {
val assetName =
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
if (name == assetName) {
preferredAsset = asset.jsonObject
break@outer
}
}
}
return preferredAsset
?.get("browser_download_url")
?.jsonPrimitive
?.contentOrNull
}

View file

@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -15,6 +16,8 @@ class UserPreferencesService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
) { ) {
val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences = suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig -> serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi 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.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
@ -17,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -48,6 +50,14 @@ annotation class IoCoroutineScope
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope annotation class DefaultCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object AppModule { object AppModule {
@ -61,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME, version = BuildConfig.VERSION_NAME,
) )
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
@StandardOkHttpClient @StandardOkHttpClient
@Provides @Provides
@Singleton @Singleton
@ -111,12 +115,12 @@ object AppModule {
@Singleton @Singleton
fun okHttpFactory( fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient) ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides @Provides
@Singleton @Singleton
fun jellyfin( fun jellyfin(
okHttpFactory: OkHttpFactory, okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context, @ApplicationContext context: Context,
clientInfo: ClientInfo, clientInfo: ClientInfo,
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,
@ -167,7 +171,7 @@ object AppModule {
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) { if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
scope.launch(ExceptionHandler()) { scope.launch(ExceptionHandler()) {
appPreference.updateData { appPreference.updateData {
preferences.appPreferences.updateInterfacePreferences { it.updateInterfacePreferences {
putRememberedTabs(key(itemId), tabIndex) putRememberedTabs(key(itemId), tabIndex)
} }
} }
@ -176,15 +180,29 @@ object AppModule {
} }
} }
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides @Provides
@Singleton @Singleton
@IoCoroutineScope @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 @Provides
@Singleton @Singleton
@DefaultCoroutineScope @DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides @Provides
@Singleton @Singleton
@ -198,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient) ) = SeerrApi(okHttpClient)
} }
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
}

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.media.AudioManager import android.media.AudioManager
import android.view.KeyEvent import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee 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.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID 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? { fun Context.findActivity(): Activity? {
if (this is Activity) { if (this is Activity) {
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
return null 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. * Selectively log errors from Coil image loading.
* *
@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } ) = 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 * Check if this, coalescing nulls to zero, is greater than that
*/ */

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R 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.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaSegmentType
import timber.log.Timber import timber.log.Timber
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
append(productionYear.toString()) append(productionYear.toString())
if (status == "Continuing") { if (status == "Continuing") {
append(" - ") append(" - ")
append("Present") append(WholphinApplication.instance.getString(R.string.series_continueing))
} else if (status == "Ended") { } else if (status == "Ended") {
endDate?.let { endDate?.let {
if (it.year != productionYear) { if (it.year != productionYear) {

View file

@ -58,6 +58,7 @@ val DefaultItemFields =
ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCES,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
) )
/** /**
@ -72,6 +73,7 @@ val SlimItemFields =
ItemFields.SORT_NAME, ItemFields.SORT_NAME,
ItemFields.MEDIA_SOURCE_COUNT, ItemFields.MEDIA_SOURCE_COUNT,
ItemFields.PARENT_ID, ItemFields.PARENT_ID,
ItemFields.CAN_DELETE,
) )
val PhotoItemFields = val PhotoItemFields =

View file

@ -68,6 +68,7 @@ fun BannerCard(
interactionSource: MutableInteractionSource? = null, interactionSource: MutableInteractionSource? = null,
imageType: ImageType = ImageType.PRIMARY, imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds, imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = true,
) { ) {
val imageUrlService = LocalImageUrlService.current val imageUrlService = LocalImageUrlService.current
val density = LocalDensity.current val density = LocalDensity.current
@ -82,7 +83,7 @@ fun BannerCard(
} }
} }
val imageUrl = val imageUrl =
remember(item, fillHeight, imageType) { remember(item, fillHeight, imageType, useSeriesForPrimary) {
if (item != null) { if (item != null) {
item.imageUrlOverride item.imageUrlOverride
?: imageUrlService.getItemImageUrl( ?: imageUrlService.getItemImageUrl(
@ -90,6 +91,7 @@ fun BannerCard(
imageType, imageType,
fillWidth = null, fillWidth = null,
fillHeight = fillHeight, fillHeight = fillHeight,
useSeriesForPrimary = useSeriesForPrimary,
) )
} else { } else {
null null
@ -208,6 +210,7 @@ fun BannerCardWithTitle(
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageType: ImageType = ImageType.PRIMARY, imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds, imageContentScale: ContentScale = ContentScale.FillBounds,
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
) { ) {
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
@ -234,6 +237,7 @@ fun BannerCardWithTitle(
interactionSource = interactionSource, interactionSource = interactionSource,
imageType = imageType, imageType = imageType,
imageContentScale = imageContentScale, imageContentScale = imageContentScale,
useSeriesForPrimary = useSeriesForPrimary,
) )
Column( Column(
verticalArrangement = Arrangement.spacedBy(0.dp), verticalArrangement = Arrangement.spacedBy(0.dp),

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -57,7 +58,7 @@ fun <T> ItemRow(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground, color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier, modifier = Modifier.padding(start = 8.dp),
) )
LazyRow( LazyRow(
state = state, state = state,

View file

@ -0,0 +1,214 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.Shader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.AsyncImage
import coil3.compose.useExistingImageAsPlaceholder
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.CrossFadeFactory
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA
import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@HiltViewModel
class ScreensaverViewModel
@Inject
constructor(
private val screensaverService: ScreensaverService,
val preferencesDataStore: DataStore<AppPreferences>,
) : ViewModel() {
val currentItem = screensaverService.createItemFlow(viewModelScope)
}
data class CurrentItem(
val item: BaseItem,
val backdropUrl: String,
val logoUrl: String?,
val title: String,
)
@Composable
fun AppScreensaver(
prefs: AppPreferences,
modifier: Modifier = Modifier,
viewModel: ScreensaverViewModel = hiltViewModel(),
) {
val currentItem by viewModel.currentItem.collectAsState(null)
AppScreensaverContent(
currentItem = currentItem,
showClock = prefs.interfacePreferences.screensaverPreference.showClock,
duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds,
animate = prefs.interfacePreferences.screensaverPreference.animate,
modifier = modifier,
)
}
@OptIn(ExperimentalCoilApi::class)
@Composable
fun AppScreensaverContent(
currentItem: CurrentItem?,
showClock: Boolean,
duration: Duration,
animate: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier
.background(Color.Black),
) {
val infiniteTransition = rememberInfiniteTransition()
val scale by infiniteTransition.animateFloat(
1f,
if (animate) 1.1f else 1f,
infiniteRepeatable(
tween(
durationMillis = duration.inWholeMilliseconds.toInt(),
delayMillis = 500,
easing = LinearEasing,
),
repeatMode = RepeatMode.Reverse,
),
)
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.backdropUrl)
.transitionFactory(CrossFadeFactory(2000.milliseconds))
.useExistingImageAsPlaceholder(true)
.build(),
contentDescription = null,
modifier =
Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = scale
scaleY = scale
},
)
var logoError by remember(currentItem) { mutableStateOf(false) }
if (!logoError) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(currentItem?.logoUrl)
.transitionFactory(CrossFadeFactory(750.milliseconds))
.build(),
contentDescription = "Logo",
onError = {
logoError = true
},
modifier =
Modifier
.align(Alignment.BottomStart)
.size(width = 240.dp, height = 120.dp)
.padding(16.dp),
)
} else {
Box(
modifier =
Modifier
.align(Alignment.BottomStart)
.padding(16.dp)
.fillMaxWidth(.5f)
.fillMaxHeight(.3f),
) {
Text(
text = currentItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart),
)
}
}
val largeRadialGradient =
remember {
object : ShaderBrush() {
override fun createShader(size: Size): Shader {
val biggerDimension = maxOf(size.height, size.width)
return RadialGradientShader(
colors = listOf(Color.Transparent, AppColors.TransparentBlack25),
center = size.center,
radius = biggerDimension / 1.5f,
colorStops = listOf(0f, 0.85f),
)
}
}
}
Canvas(Modifier.fillMaxSize()) {
drawRect(
brush = largeRadialGradient,
blendMode = BlendMode.Multiply,
)
if (showClock) {
// Add scrim to make clock more readable
drawRect(
brush =
Brush.verticalGradient(
colorStops =
arrayOf(
0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA),
TOP_SCRIM_END_FRACTION to Color.Transparent,
),
),
blendMode = BlendMode.Multiply,
)
}
}
if (showClock) {
TimeDisplay()
}
}
}

View file

@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.focus.focusRequester
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text 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.GetItemsFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager 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.AspectRatios
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.SlimItemFields 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.playback.scale
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.setValueOnMain 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.toServerString
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.ui.util.FilterUtils 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.ApiRequestPager
import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.ExceptionHandler 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.ApiClient
@ -120,7 +134,10 @@ class CollectionFolderViewModel
private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val backdropService: BackdropService, 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, val mediaReportService: MediaReportService,
@Assisted itemId: String, @Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?, @Assisted initialSortAndDirection: SortAndDirection?,
@ -157,6 +174,7 @@ class CollectionFolderViewModel
viewModelScope.launchIO { viewModelScope.launchIO {
super.itemId = itemId super.itemId = itemId
try { try {
val item =
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
fetchItem(it) fetchItem(it)
} }
@ -184,11 +202,42 @@ class CollectionFolderViewModel
} }
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
.join()
// onResumePage()
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error during init") Timber.e(ex, "Error during init")
loading.setValueOnMain(DataLoadingState.Error(ex)) 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( private fun saveLibraryDisplayInfo(
@ -254,8 +303,7 @@ class CollectionFolderViewModel
recursive: Boolean, recursive: Boolean,
filter: GetItemsFilter, filter: GetItemsFilter,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) { ) = viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(Dispatchers.IO) {
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (resetState) { if (resetState) {
loading.value = DataLoadingState.Loading loading.value = DataLoadingState.Loading
@ -284,7 +332,6 @@ class CollectionFolderViewModel
} }
} }
} }
}
private fun createPager( private fun createPager(
sortAndDirection: SortAndDirection, sortAndDirection: SortAndDirection,
@ -443,6 +490,46 @@ class CollectionFolderViewModel
backdropService.submit(item) backdropService.submit(item)
} }
} }
fun navigateTo(destination: Destination) {
release()
navigationManager.navigateTo(destination)
}
fun release() {
themeSongPlayer.stop()
}
fun onResumePage() {
viewModelScope.launchIO {
item.value?.let {
Timber.v("onResumePage: %s", loading.value!!::class)
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
val volume =
userPreferencesService
.getCurrent()
.appPreferences.interfacePreferences.playThemeSongs
themeSongPlayer.playThemeFor(it.id, volume)
}
}
}
}
fun deleteItem(
index: Int,
item: BaseItem,
) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
refreshAfterDelete(index, item)
}
}
}
fun canDelete(
item: BaseItem,
appPreferences: AppPreferences,
): Boolean = mediaManagementService.canDelete(item, appPreferences)
} }
/** /**
@ -530,6 +617,7 @@ fun CollectionFolderGrid(
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
when (val state = loading) { when (val state = loading) {
@ -548,6 +636,13 @@ fun CollectionFolderGrid(
?: item?.data?.collectionType?.name ?: item?.data?.collectionType?.name
?: stringResource(R.string.collection) ?: stringResource(R.string.collection)
Box(modifier = modifier) { Box(modifier = modifier) {
LifecycleResumeEffect(itemId) {
viewModel.onResumePage()
onPauseOrDispose {
viewModel.release()
}
}
CollectionFolderGridContent( CollectionFolderGridContent(
preferences = preferences, preferences = preferences,
initialPosition = viewModel.position, initialPosition = viewModel.position,
@ -598,7 +693,7 @@ fun CollectionFolderGrid(
} else { } else {
Destination.Playback(item) Destination.Playback(item)
} }
viewModel.navigationManager.navigateTo(destination) viewModel.navigateTo(destination)
}, },
onClickPlayAll = { shuffle -> onClickPlayAll = { shuffle ->
itemId.toUUIDOrNull()?.let { itemId.toUUIDOrNull()?.let {
@ -622,7 +717,7 @@ fun CollectionFolderGrid(
filter = filter, filter = filter,
) )
} }
viewModel.navigationManager.navigateTo(destination) viewModel.navigateTo(destination)
} }
}, },
) )
@ -658,9 +753,10 @@ fun CollectionFolderGrid(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) }, navigateTo = { viewModel.navigateTo(it) },
onClickWatch = { itemId, watched -> onClickWatch = { itemId, watched ->
viewModel.setWatched(position, itemId, watched) viewModel.setWatched(position, itemId, watched)
}, },
@ -672,6 +768,12 @@ fun CollectionFolderGrid(
showPlaylistDialog.makePresent(it) showPlaylistDialog.makePresent(it)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = PositionItem(position, item)
},
onClickGoTo = {
onClickItem.invoke(position, it)
},
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },
@ -696,8 +798,19 @@ fun CollectionFolderGrid(
elevation = 3.dp, 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 @Composable
fun CollectionFolderGridContent( fun CollectionFolderGridContent(
preferences: UserPreferences, preferences: UserPreferences,
@ -760,8 +873,8 @@ fun CollectionFolderGridContent(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader || loadingState !is DataLoadingState.Success, showHeader || loadingState !is DataLoadingState.Success,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
@ -842,14 +955,16 @@ fun CollectionFolderGridContent(
} }
} }
} }
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
val density = LocalDensity.current
AnimatedVisibility(viewOptions.showDetails) { AnimatedVisibility(viewOptions.showDetails) {
HomePageHeader( HomePageHeader(
item = focusedItem, item = focusedItem,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.height(140.dp) .height(200.dp)
.padding(16.dp), .padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
) )
} }
when (val state = loadingState) { when (val state = loadingState) {
@ -857,7 +972,7 @@ fun CollectionFolderGridContent(
DataLoadingState.Loading, DataLoadingState.Loading,
-> { -> {
// This shouldn't happen, so just show placeholder // This shouldn't happen, so just show placeholder
Text("Loading") Text(stringResource(R.string.loading))
} }
is DataLoadingState.Error -> { is DataLoadingState.Error -> {
@ -895,6 +1010,15 @@ fun CollectionFolderGridContent(
}, },
columns = viewOptions.columns, columns = viewOptions.columns,
spacing = viewOptions.spacing.dp, spacing = viewOptions.spacing.dp,
bringIntoViewSpec =
remember(viewOptions) {
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
if (viewOptions.showDetails) {
ScrollToTopBringIntoViewSpec(spacingPx)
} else {
defaultBringIntoViewSpec
}
},
) )
AnimatedVisibility(showViewOptions) { AnimatedVisibility(showViewOptions) {
ViewOptionsDialog( ViewOptionsDialog(

View file

@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope 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.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow 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.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector 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.R
import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome 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.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream 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 com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -259,31 +271,65 @@ fun DialogPopupContent(
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
LazyColumn( LazyColumn(
state = listState,
modifier = Modifier, modifier = Modifier,
) { ) {
items(dialogItems) { itemsIndexed(dialogItems) { index, item ->
when (it) { when (item) {
is DialogItemDivider -> { is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp)) HorizontalDivider(Modifier.height(16.dp))
} }
is DialogItem -> { is DialogItem -> {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem( ListItem(
selected = it.selected, selected = item.selected,
enabled = !waiting && it.enabled, enabled = !waiting && item.enabled,
onClick = { onClick = {
if (dismissOnClick) { if (dismissOnClick) {
onDismissRequest.invoke() onDismissRequest.invoke()
} }
it.onClick.invoke() item.onClick.invoke()
}, },
headlineContent = it.headlineContent, headlineContent = item.headlineContent,
overlineContent = it.overlineContent, overlineContent = item.overlineContent,
supportingContent = it.supportingContent, supportingContent = item.supportingContent,
leadingContent = it.leadingContent, leadingContent = item.leadingContent,
trailingContent = it.trailingContent, trailingContent = item.trailingContent,
modifier = Modifier, 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( fun chooseVersionParams(
context: Context, context: Context,
sources: List<MediaSourceInfo>, sources: List<MediaSourceInfo>,
@ -512,6 +632,7 @@ fun chooseStream(
streams: List<MediaStream>, streams: List<MediaStream>,
currentIndex: Int?, currentIndex: Int?,
type: MediaStreamType, type: MediaStreamType,
preferredSubtitleLanguage: String?,
onClick: (Int) -> Unit, onClick: (Int) -> Unit,
): DialogParams = ): DialogParams =
DialogParams( DialogParams(
@ -536,6 +657,9 @@ fun chooseStream(
) )
add( add(
DialogItem( DialogItem(
leadingContent = {
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
},
headlineContent = { headlineContent = {
Text(text = stringResource(R.string.only_forced_subtitles)) Text(text = stringResource(R.string.only_forced_subtitles))
}, },
@ -546,7 +670,15 @@ fun chooseStream(
) )
} }
addAll( addAll(
streams.filter { it.type == type }.mapIndexed { index, stream -> 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) val simpleStream = SimpleMediaStream.from(context, stream, true)
DialogItem( DialogItem(
selected = currentIndex == stream.index, selected = currentIndex == stream.index,
@ -554,7 +686,9 @@ fun chooseStream(
SelectedLeadingContent(currentIndex == stream.index) SelectedLeadingContent(currentIndex == stream.index)
}, },
headlineContent = { headlineContent = {
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle) Text(
text = simpleStream.streamTitle ?: simpleStream.displayTitle,
)
}, },
supportingContent = { supportingContent = {
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle) if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)

View file

@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.createGenreDestination
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect 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.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem 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.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import com.mayakapps.kache.InMemoryKache
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject 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.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import timber.log.Timber
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.hours
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class) @HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel class GenreViewModel
@ -112,6 +113,7 @@ class GenreViewModel
val genreToUrl = val genreToUrl =
getGenreImageMap( getGenreImageMap(
api = api, api = api,
userId = serverRepository.currentUser.value?.id,
scope = viewModelScope, scope = viewModelScope,
imageUrlService = imageUrlService, imageUrlService = imageUrlService,
genres = genres.map { it.id }, genres = genres.map { it.id },
@ -143,15 +145,35 @@ class GenreViewModel
} }
} }
data class GenreCacheKey(
val userId: UUID?,
val parentId: UUID,
)
private val genreCache by lazy {
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
expireAfterWriteDuration = 2.hours
}
}
suspend fun getGenreImageMap( suspend fun getGenreImageMap(
api: ApiClient, api: ApiClient,
userId: UUID?,
scope: CoroutineScope, scope: CoroutineScope,
imageUrlService: ImageUrlService, imageUrlService: ImageUrlService,
genres: List<UUID>, genres: List<UUID>,
parentId: UUID, parentId: UUID,
includeItemTypes: List<BaseItemKind>?, includeItemTypes: List<BaseItemKind>?,
cardWidthPx: Int?, cardWidthPx: Int?,
useCache: Boolean = true,
): Map<UUID, String?> { ): Map<UUID, String?> {
val key = GenreCacheKey(userId, parentId)
if (useCache) {
genreCache.getIfAvailable(key)?.let {
Timber.v("Got cached entry")
return it
}
}
val genreToUrl = ConcurrentHashMap<UUID, String?>() val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
genres genres
@ -163,6 +185,7 @@ suspend fun getGenreImageMap(
.execute( .execute(
api, api,
GetItemsRequest( GetItemsRequest(
userId = userId,
parentId = parentId, parentId = parentId,
recursive = true, recursive = true,
limit = 1, limit = 1,
@ -186,11 +209,13 @@ suspend fun getGenreImageMap(
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(), imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx, fillWidth = cardWidthPx,
backdropTags = item.backdropImageTags.orEmpty(),
) )
} }
} }
} }
}.awaitAll() }.awaitAll()
genreCache.put(key, genreToUrl)
return genreToUrl return genreToUrl
} }
@ -256,24 +281,13 @@ fun GenreCardGrid(
pager = genres, pager = genres,
onClickItem = { _, genre -> onClickItem = { _, genre ->
viewModel.navigationManager.navigateTo( viewModel.navigationManager.navigateTo(
Destination.FilteredCollection( createGenreDestination(
itemId = itemId, genreId = genre.id,
filter = genreName = genre.name,
CollectionFolderFilter( parentId = itemId,
nameOverride = parentName = item?.title,
listOfNotNull(
genre.name,
item?.title,
).joinToString(" "),
filter =
GetItemsFilter(
genres = listOf(genre.id),
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
), ),
useSavedLibraryDisplayInfo = false,
),
recursive = true,
),
) )
}, },
onLongClickItem = { _, _ -> }, onLongClickItem = { _, _ -> },

View file

@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility 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.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row 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.layout.size
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons 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.MoreVert
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh 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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
@ -65,12 +72,14 @@ fun ExpandablePlayButtons(
resumePosition: Duration, resumePosition: Duration,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean,
trailers: List<Trailer>?, trailers: List<Trailer>?,
playOnClick: (position: Duration) -> Unit, playOnClick: (position: Duration) -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
deleteOnClick: () -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -151,6 +160,16 @@ fun ExpandablePlayButtons(
) )
} }
} }
if (canDelete) {
item("delete") {
DeleteButton(
onClick = deleteOnClick,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
// More button // More button
item("more") { item("more") {
@ -234,7 +253,7 @@ fun ExpandablePlayButton(
fun ExpandablePlayButton( fun ExpandablePlayButton(
@StringRes title: Int, @StringRes title: Int,
resume: Duration, resume: Duration,
icon: @Composable () -> Unit, icon: @Composable BoxScope.() -> Unit,
onClick: (position: Duration) -> Unit, onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@ -254,12 +273,13 @@ fun ExpandablePlayButton(
interactionSource = interactionSource, interactionSource = interactionSource,
) { ) {
Box( Box(
contentAlignment = Alignment.Center,
modifier = modifier =
Modifier Modifier
.padding(start = 2.dp, top = 2.dp) .padding(start = 2.dp)
.height(MinButtonSize), .height(MinButtonSize),
) { ) {
icon.invoke() icon.invoke(this)
} }
AnimatedVisibility(isFocused) { AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp)) 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 @PreviewTvSpec
@Composable @Composable
private fun ExpandablePlayButtonsPreview() { private fun ExpandablePlayButtonsPreview() {
@ -374,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
buttonOnFocusChanged = {}, buttonOnFocusChanged = {},
trailers = listOf(), trailers = listOf(),
trailerOnClick = {}, trailerOnClick = {},
canDelete = true,
deleteOnClick = {},
modifier = Modifier, modifier = Modifier,
) )
} }
@ -417,12 +481,19 @@ private fun ViewOptionsPreview() {
onClick = {}, onClick = {},
modifier = Modifier, modifier = Modifier,
) )
DeleteButton(
onClick = {},
)
SortByButton( SortByButton(
sortOptions = listOf(), sortOptions = listOf(),
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
onSortChange = {}, onSortChange = {},
) )
} }
DeleteButton(
onClick = {},
interactionSource = source,
)
} }
} }
} }

View file

@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager 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.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome 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.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -51,6 +55,7 @@ abstract class RecommendedViewModel(
val favoriteWatchManager: FavoriteWatchManager, val favoriteWatchManager: FavoriteWatchManager,
val mediaReportService: MediaReportService, val mediaReportService: MediaReportService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() { ) : ViewModel() {
abstract fun init() abstract fun init()
@ -117,6 +122,25 @@ abstract class RecommendedViewModel(
} }
update(title, row) 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 @Composable
@ -130,6 +154,7 @@ fun RecommendedContent(
val context = LocalContext.current val context = LocalContext.current
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
@ -196,6 +221,7 @@ fun RecommendedContent(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = { viewModel.navigationManager.navigateTo(it) }, navigateTo = { viewModel.navigationManager.navigateTo(it) },
@ -210,6 +236,7 @@ fun RecommendedContent(
showPlaylistDialog.makePresent(it) showPlaylistDialog.makePresent(it)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
), ),
), ),
onDismissRequest = { moreDialog.makeAbsent() }, onDismissRequest = { moreDialog.makeAbsent() },
@ -234,9 +261,19 @@ fun RecommendedContent(
elevation = 3.dp, 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 position: RowColumn,
val item: BaseItem, val item: BaseItem,
) )

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionService
@ -63,12 +64,14 @@ class RecommendedMovieViewModel
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService, mediaReportService: MediaReportService,
backdropService: BackdropService, backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel( ) : RecommendedViewModel(
context, context,
navigationManager, navigationManager,
favoriteWatchManager, favoriteWatchManager,
mediaReportService, mediaReportService,
backdropService, backdropService,
mediaManagementService,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

View file

@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.LatestNextUpService 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionService
@ -67,12 +68,14 @@ class RecommendedTvShowViewModel
favoriteWatchManager: FavoriteWatchManager, favoriteWatchManager: FavoriteWatchManager,
mediaReportService: MediaReportService, mediaReportService: MediaReportService,
backdropService: BackdropService, backdropService: BackdropService,
mediaManagementService: MediaManagementService,
) : RecommendedViewModel( ) : RecommendedViewModel(
context, context,
navigationManager, navigationManager,
favoriteWatchManager, favoriteWatchManager,
mediaReportService, mediaReportService,
backdropService, backdropService,
mediaManagementService,
) { ) {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {

View file

@ -5,6 +5,7 @@ import android.widget.Toast
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.launchIO 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.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@ -43,7 +45,13 @@ class AddPlaylistViewModel
itemId: UUID, itemId: UUID,
) { ) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
try {
playlistCreator.addToServerPlaylist(playlistId, itemId) 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)) val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId))
if (playlistId == null) { if (playlistId == null) {
showToast(context, "Error creating playlist", Toast.LENGTH_LONG) showToast(context, "Error creating playlist", Toast.LENGTH_LONG)
} else {
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
} }
} }
} }

View file

@ -4,6 +4,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup 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.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement 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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
}, },
columns: Int = 6, columns: Int = 6,
spacing: Dp = 16.dp, spacing: Dp = 16.dp,
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
) { ) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
@ -269,6 +273,7 @@ fun <T : CardGridItem> CardGrid(
Box( Box(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) { ) {
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Fixed(columns), columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(spacing), horizontalArrangement = Arrangement.spacedBy(spacing),
@ -366,6 +371,7 @@ fun <T : CardGridItem> CardGrid(
} }
} }
} }
}
val context = LocalContext.current val context = LocalContext.current
val letters = context.getString(R.string.jump_letters) val letters = context.getString(R.string.jump_letters)
// Letters // Letters

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -127,8 +125,8 @@ fun CollectionFolderLiveTv(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -76,8 +74,8 @@ fun CollectionFolderMovie(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -59,13 +59,13 @@ fun CollectionFolderPhotoAlbum(
index = index, index = index,
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
recursive = true, recursive = recursive,
startSlideshow = false, startSlideshow = false,
) )
} else { } else {
item.destination(index) item.destination(index)
} }
viewModel.navigationManager.navigateTo(destination) viewModel.navigateTo(destination)
}, },
itemId = itemId.toServerString(), itemId = itemId.toServerString(),
initialFilter = filter, initialFilter = filter,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -80,8 +78,8 @@ fun CollectionFolderTv(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -29,6 +29,8 @@ data class MoreDialogActions(
val onClickFavorite: (UUID, Boolean) -> Unit, val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit, val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit,
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
) )
enum class ClearChosenStreams { enum class ClearChosenStreams {
@ -62,6 +64,7 @@ fun buildMoreDialogItems(
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canClearChosenStreams: Boolean, canClearChosenStreams: Boolean,
canDelete: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
onChooseVersion: () -> Unit, onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit, onChooseTracks: (MediaStreamType) -> Unit,
@ -139,6 +142,17 @@ fun buildMoreDialogItems(
actions.onClickAddPlaylist.invoke(item.id) 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( add(
DialogItem( DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@ -223,6 +237,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration, playbackPosition: Duration,
watched: Boolean, watched: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean,
actions: MoreDialogActions, actions: MoreDialogActions,
): List<DialogItem> = ): List<DialogItem> =
buildList { buildList {
@ -232,7 +247,7 @@ fun buildMoreDialogItemsForHome(
context.getString(R.string.go_to), context.getString(R.string.go_to),
Icons.Default.ArrowForward, Icons.Default.ArrowForward,
) { ) {
actions.navigateTo(item.destination()) actions.onClickGoTo(item)
}, },
) )
if (item.type in supportedPlayableTypes) { if (item.type in supportedPlayableTypes) {
@ -290,6 +305,17 @@ fun buildMoreDialogItemsForHome(
actions.onClickAddPlaylist.invoke(itemId) 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( add(
DialogItem( DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched, text = if (watched) R.string.mark_unwatched else R.string.mark_watched,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -98,8 +96,8 @@ fun FavoritesPage(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -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.components.LoadingPage
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
@ -102,15 +101,6 @@ fun DiscoverMovieDetails(
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) 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) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)

View file

@ -103,7 +103,7 @@ class DiscoverMovieViewModel
) { ) {
Timber.v("Init for movie %s", item.id) Timber.v("Init for movie %s", item.id)
val movie = fetchAndSetItem().await() val movie = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(movie) val discoveredItem = seerrService.createDiscoverItem(movie)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateCanCancel() updateCanCancel()
@ -121,7 +121,7 @@ class DiscoverMovieViewModel
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdSimilarGet(movieId = item.id, page = 1) .movieMovieIdSimilarGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
similar.setValueOnMain(result) similar.setValueOnMain(result)
} }
@ -130,7 +130,7 @@ class DiscoverMovieViewModel
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1) .movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
recommended.setValueOnMain(result) recommended.setValueOnMain(result)
} }
@ -138,11 +138,11 @@ class DiscoverMovieViewModel
val people = val people =
movie.credits movie.credits
?.cast ?.cast
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() + .orEmpty() +
movie.credits movie.credits
?.crew ?.crew
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
this@DiscoverMovieViewModel.people.setValueOnMain(people) this@DiscoverMovieViewModel.people.setValueOnMain(people)
val trailers = val trailers =

View file

@ -67,11 +67,11 @@ class DiscoverPersonViewModel
.let { credits -> .let { credits ->
val cast = val cast =
credits.cast credits.cast
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
val crew = val crew =
credits.crew credits.crew
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
cast + crew cast + crew
} }

View file

@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R 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.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestSeasonDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) val request4kStr = stringResource(R.string.request_4k)
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
trailers = trailers, trailers = trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
if (request4kEnabled) { showRequestSeasonDialog = true
moreDialog =
DialogParams(
fromLongClick = false,
title = item.name ?: "",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
} }
}, },
cancelOnClick = { cancelOnClick = {
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
waitToLoad = params.fromLongClick, 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 private const val HEADER_ROW = 0
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
series: TvDetails, series: TvDetails,
rating: DiscoverRating?, rating: DiscoverRating?,
canCancel: Boolean, canCancel: Boolean,
seasons: List<Season>, seasons: List<RequestSeason>,
similar: List<DiscoverItem>, similar: List<DiscoverItem>,
recommended: List<DiscoverItem>, recommended: List<DiscoverItem>,
trailers: List<Trailer>, trailers: List<Trailer>,
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
SeerrAvailability.from(series.mediaInfo?.status) SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN, ?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick, requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick, cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
goToOnClick = goToOnClick, goToOnClick = goToOnClick,

View file

@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo 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.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.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer 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.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
val tvSeries = MutableLiveData<TvDetails?>(null) val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null) val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<Season>>(listOf()) val seasons = MutableLiveData<List<RequestSeason>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>() val similar = MutableLiveData<List<DiscoverItem>>()
@ -100,9 +102,10 @@ class DiscoverSeriesViewModel
) { ) {
Timber.v("Init for tv %s", item.id) Timber.v("Init for tv %s", item.id)
val tv = fetchAndSetItem().await() val tv = fetchAndSetItem().await()
val discoveredItem = DiscoverItem(tv) val discoveredItem = seerrService.createDiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateSeasonStatus()
updateCanCancel() updateCanCancel()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -118,7 +121,7 @@ class DiscoverSeriesViewModel
seerrService.api.tvApi seerrService.api.tvApi
.tvTvIdSimilarGet(tvId = item.id, page = 1) .tvTvIdSimilarGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
similar.setValueOnMain(result) similar.setValueOnMain(result)
} }
@ -127,7 +130,7 @@ class DiscoverSeriesViewModel
seerrService.api.tvApi seerrService.api.tvApi
.tvTvIdRecommendationsGet(tvId = item.id, page = 1) .tvTvIdRecommendationsGet(tvId = item.id, page = 1)
.results .results
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
recommended.setValueOnMain(result) recommended.setValueOnMain(result)
} }
@ -135,11 +138,11 @@ class DiscoverSeriesViewModel
val people = val people =
tv.credits tv.credits
?.cast ?.cast
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() + .orEmpty() +
tv.credits tv.credits
?.crew ?.crew
?.map(::DiscoverItem) ?.map { seerrService.createDiscoverItem(it) }
.orEmpty() .orEmpty()
this@DiscoverSeriesViewModel.people.setValueOnMain(people) this@DiscoverSeriesViewModel.people.setValueOnMain(people)
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
private suspend fun updateSeasonStatus() {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
}
seasons.setValueOnMain(requestSeasons)
}
}
private suspend fun updateCanCancel() { private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull() val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
@ -165,22 +205,45 @@ class DiscoverSeriesViewModel
fun request( fun request(
id: Int, id: Int,
seasons: Set<Int>,
is4k: Boolean, is4k: Boolean,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val request = 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( seerrService.api.requestApi.requestPost(
RequestPostRequest( RequestPostRequest(
is4k = is4k, is4k = is4k,
mediaId = id, mediaId = id,
mediaType = RequestPostRequest.MediaType.TV, mediaType = RequestPostRequest.MediaType.TV,
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons seasons = seasons.toList(),
), ),
) )
}
fetchAndSetItem().await() fetchAndSetItem().await()
updateSeasonStatus()
updateCanCancel() updateCanCancel()
} }
} }
}
fun cancelRequest(id: Int) { fun cancelRequest(id: Int) {
viewModelScope.launchIO { viewModelScope.launchIO {

View file

@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pendingOnClick: () -> Unit = {},
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
LazyRow( LazyRow(
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
SeerrAvailability.PENDING, SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING, SeerrAvailability.PROCESSING,
-> { -> {
// TODO? pendingOnClick.invoke()
} }
SeerrAvailability.PARTIALLY_AVAILABLE, SeerrAvailability.PARTIALLY_AVAILABLE,
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
.onFocusChanged(buttonOnFocusChanged), .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) { if (canCancel) {
item("cancel") { item("cancel") {

View file

@ -0,0 +1,323 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PendingIndicator
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
data class RequestSeason(
val season: Season,
val availability: SeerrAvailability,
)
@Composable
fun RequestSeasons(
title: String,
seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit,
request4kEnabled: Boolean,
modifier: Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected =
remember {
mutableStateSetOf<Int>(
*seasons
.mapNotNull {
if (it.availability > SeerrAvailability.UNKNOWN) {
it.season.seasonNumber
} else {
null
}
}.toTypedArray(),
)
}
var is4k by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn {
item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selected.removeAll(allSeasonNumbers)
} else {
selected.addAll(allSeasonNumbers)
}
},
)
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
if (request4kEnabled) {
item {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected
SeasonListItem(
season = season,
selected = isSelected,
onClick = {
if (isSelected) {
selected.remove(seasonNumber)
} else {
seasonNumber?.let { selected.add(it) }
}
},
modifier = Modifier,
)
}
if (seasons.size > 7) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
}
}
}
}
@Composable
fun SeasonListItem(
season: RequestSeason,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
selected = false,
headlineContent = {
Text(
text =
season.season.name
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"),
)
},
supportingContent = {
season.season.episodeCount?.let {
Text(
// TODO should use plurals string
text = "${season.season.episodeCount} " + stringResource(R.string.episodes),
)
}
},
leadingContent = {
when (season.availability) {
SeerrAvailability.UNKNOWN -> {}
SeerrAvailability.DELETED -> {}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
PendingIndicator()
}
SeerrAvailability.PARTIALLY_AVAILABLE -> {
PartiallyAvailableIndicator()
}
SeerrAvailability.AVAILABLE -> {
AvailableIndicator()
}
}
},
trailingContent = {
Row {
Switch(
checked = selected,
onCheckedChange = {
onClick.invoke()
},
)
}
},
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun ClickSurface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Surface(
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
pressedContainerColor = MaterialTheme.colorScheme.inverseSurface,
pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
),
onClick = onClick,
content = content,
modifier = modifier,
)
}
@Composable
private fun ClickSwitch(
label: String,
checked: Boolean,
onClick: () -> Unit,
) {
ClickSurface(
onClick = onClick,
modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(horizontal = 8.dp)
.height(54.dp),
) {
Switch(
checked = checked,
onCheckedChange = {},
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = label,
)
}
}
}
@Composable
fun RequestSeasonsDialog(
title: String,
seasons: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestSeasons(
title = title,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
}
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
heightDp = 800,
)
@Composable
fun RequestSeasonsPreview() {
val seasons =
List(10) {
RequestSeason(
season =
Season(
seasonNumber = it + 1,
episodeCount = 10 + it,
),
availability =
if (it < 3) {
SeerrAvailability.AVAILABLE
} else {
SeerrAvailability.UNKNOWN
},
)
}
WholphinTheme {
RequestSeasons(
title = "Series title",
seasons = seasons,
request4kEnabled = true,
onSubmit = { _, _ -> },
modifier = Modifier.width(400.dp),
)
}
}

View file

@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -82,8 +83,16 @@ fun EpisodeDetails(
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -98,6 +107,7 @@ fun EpisodeDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
) )
when (val state = loading) { when (val state = loading) {
@ -197,6 +207,7 @@ fun EpisodeDetails(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },
@ -215,6 +226,7 @@ fun EpisodeDetails(
onClearChosenStreams = { onClearChosenStreams = {
viewModel.clearChosenStreams(chosenStreams) viewModel.clearChosenStreams(chosenStreams)
}, },
canDelete = viewModel.canDelete,
), ),
) )
}, },
@ -224,6 +236,8 @@ fun EpisodeDetails(
favoriteOnClick = { favoriteOnClick = {
viewModel.setFavorite(ep.id, !ep.favorite) viewModel.setFavorite(ep.id, !ep.favorite)
}, },
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = ep },
modifier = modifier, modifier = modifier,
) )
} }
@ -276,6 +290,16 @@ fun EpisodeDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -290,6 +314,8 @@ fun EpisodeDetailsContent(
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -347,6 +373,8 @@ fun EpisodeDetailsContent(
}, },
trailers = null, trailers = null,
trailerOnClick = {}, trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.UserPreferencesService 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.launchIO
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setValueOnMain
@ -54,6 +56,7 @@ class EpisodeViewModel
private val favoriteWatchManager: FavoriteWatchManager, private val favoriteWatchManager: FavoriteWatchManager,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -65,6 +68,9 @@ class EpisodeViewModel
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
var canDelete: Boolean = false
private set
init { init {
init() init()
} }
@ -95,6 +101,7 @@ class EpisodeViewModel
) { ) {
val prefs = userPreferencesService.getCurrent() val prefs = userPreferencesService.getCurrent()
val item = fetchAndSetItem().await() val item = fetchAndSetItem().await()
canDelete = mediaManagementService.canDelete(item)
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@EpisodeViewModel.item.value = item this@EpisodeViewModel.item.value = item
@ -199,4 +206,10 @@ class EpisodeViewModel
} }
} }
} }
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
navigationManager.goBack()
}
}
} }

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -140,7 +142,11 @@ fun TvGuideGrid(
.fillMaxHeight(.30f), .fillMaxHeight(.30f),
) )
} }
AnimatedVisibility(focusedPosition.row < 1) { AnimatedVisibility(
focusedPosition.row < 1,
enter = expandVertically(),
exit = shrinkVertically(),
) {
ExpandableFaButton( ExpandableFaButton(
title = R.string.view_options, title = R.string.view_options,
iconStringRes = R.string.fa_sliders, iconStringRes = R.string.fa_sliders,

View file

@ -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.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -111,6 +112,14 @@ fun MovieDetails(
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
val moreActions = val moreActions =
MoreDialogActions( MoreDialogActions(
@ -126,6 +135,7 @@ fun MovieDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
) )
when (val state = loading) { when (val state = loading) {
@ -201,6 +211,7 @@ fun MovieDetails(
seriesId = null, seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete,
actions = moreActions, actions = moreActions,
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -242,6 +253,7 @@ fun MovieDetails(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },
@ -291,6 +303,7 @@ fun MovieDetails(
playbackPosition = similar.playbackPosition, playbackPosition = similar.playbackPosition,
watched = similar.played, watched = similar.played,
favorite = similar.favorite, favorite = similar.favorite,
canDelete = false,
actions = moreActions, actions = moreActions,
) )
moreDialog = moreDialog =
@ -310,6 +323,8 @@ fun MovieDetails(
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
}, },
canDelete = viewModel.canDelete,
deleteOnClick = { showDeleteDialog = movie },
modifier = modifier, modifier = modifier,
) )
} }
@ -362,6 +377,16 @@ fun MovieDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -395,6 +420,8 @@ fun MovieDetailsContent(
onLongClickSimilar: (Int, BaseItem) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit,
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -457,6 +484,8 @@ fun MovieDetailsContent(
position = TRAILER_ROW position = TRAILER_ROW
trailerOnClick.invoke(it) trailerOnClick.invoke(it)
}, },
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
@ -73,6 +75,7 @@ class MovieViewModel
private val extrasService: ExtrasService, private val extrasService: ExtrasService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val mediaManagementService: MediaManagementService,
@Assisted val itemId: UUID, @Assisted val itemId: UUID,
) : ViewModel() { ) : ViewModel() {
@AssistedFactory @AssistedFactory
@ -90,6 +93,9 @@ class MovieViewModel
val chosenStreams = MutableLiveData<ChosenStreams?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
var canDelete: Boolean = false
private set
init { init {
init() init()
} }
@ -106,6 +112,7 @@ class MovieViewModel
api.userLibraryApi.getItem(itemId).content.let { api.userLibraryApi.getItem(itemId).content.let {
BaseItem.from(it, api) BaseItem.from(it, api)
} }
canDelete = mediaManagementService.canDelete(item)
this@MovieViewModel.item.setValueOnMain(item) this@MovieViewModel.item.setValueOnMain(item)
item item
} }
@ -274,4 +281,10 @@ class MovieViewModel
} }
} }
} }
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
navigationManager.goBack()
}
}
} }

View file

@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel 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( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
Text(
text = stringResource(R.string.search_for, title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -190,16 +206,8 @@ fun SearchForContent(
text = stringResource(R.string.no_results), text = stringResource(R.string.no_results),
) )
} else { } else {
val titleRes =
remember {
when (searchType) {
BaseItemKind.BOX_SET -> R.string.collections
BaseItemKind.PLAYLIST -> R.string.playlists
else -> null
}
}
ItemRow( ItemRow(
title = titleRes?.let { stringResource(it) } ?: "", title = "",
items = st.items, items = st.items,
onClickItem = { _, item -> onClick.invoke(item) }, onClickItem = { _, item -> onClick.invoke(item) },
onLongClickItem = { _, _ -> }, onLongClickItem = { _, _ -> },

View file

@ -25,6 +25,8 @@ fun FocusedEpisodeFooter(
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
watchOnClick: () -> Unit, watchOnClick: () -> Unit,
favoriteOnClick: () -> Unit, favoriteOnClick: () -> Unit,
canDelete: Boolean,
deleteOnClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
buttonOnFocusChanged: (FocusState) -> Unit = {}, buttonOnFocusChanged: (FocusState) -> Unit = {},
) { ) {
@ -47,6 +49,8 @@ fun FocusedEpisodeFooter(
buttonOnFocusChanged = buttonOnFocusChanged, buttonOnFocusChanged = buttonOnFocusChanged,
trailers = null, trailers = null,
trailerOnClick = {}, trailerOnClick = {},
canDelete = canDelete,
deleteOnClick = deleteOnClick,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusState import androidx.compose.ui.focus.FocusState
@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier, modifier = modifier,
) { ) {
EpisodeName(dto, modifier = Modifier) EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
ep?.ui?.quickDetails?.let { ep?.ui?.quickDetails?.let {
QuickDetails(it, ep.timeRemainingOrRuntime) QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
} }
if (dto != null) { if (dto != null) {
VideoStreamDetails( VideoStreamDetails(
chosenStreams = chosenStreams, chosenStreams = chosenStreams,
numberOfVersions = dto.mediaSourceCount ?: 0, numberOfVersions = dto.mediaSourceCount ?: 0,
modifier = Modifier, modifier = Modifier.padding(start = 8.dp),
) )
} }
OverviewText( OverviewText(

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin.ui.detail.series package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context 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.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -24,12 +28,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard 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.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup 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.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData 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.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
@ -102,21 +111,26 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading) val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState() val item by viewModel.item.observeAsState()
val canDelete by viewModel.canDeleteSeries.collectAsState()
val seasons by viewModel.seasons.observeAsState(listOf()) val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState() val discovered by viewModel.discovered.collectAsState()
val discoverSeries by viewModel.discoverSeries.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) } var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
@ -150,6 +164,7 @@ fun SeriesDetails(
similar = similar, similar = similar,
played = played, played = played,
favorite = item.data.userData?.isFavorite ?: false, favorite = item.data.userData?.isFavorite ?: false,
canDelete = canDelete,
modifier = modifier, modifier = modifier,
onClickItem = { index, item -> onClickItem = { index, item ->
viewModel.navigateTo(item.destination()) viewModel.navigateTo(item.destination())
@ -163,10 +178,12 @@ fun SeriesDetails(
) )
}, },
onLongClickItem = { index, season -> onLongClickItem = { index, season ->
scope.launchDefault {
seasonDialog = seasonDialog =
buildDialogForSeason( buildDialogForSeason(
context = context, context = context,
s = season, s = season,
canDelete = viewModel.canDelete(season),
onClickItem = { viewModel.navigateTo(it.destination()) }, onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played -> markPlayed = { played ->
viewModel.setSeasonWatched(season.id, played) viewModel.setSeasonWatched(season.id, played)
@ -179,7 +196,11 @@ fun SeriesDetails(
), ),
) )
}, },
onClickDelete = {
showDeleteDialog = it
},
) )
}
}, },
overviewOnClick = { overviewOnClick = {
overviewDialog = overviewDialog =
@ -213,6 +234,12 @@ fun SeriesDetails(
onClickExtra = { _, extra -> onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination) viewModel.navigateTo(extra.destination)
}, },
discoverSeries = discoverSeries,
onClickDiscoverSeries = {
discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = discovered, discovered = discovered,
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
@ -231,6 +258,9 @@ fun SeriesDetails(
showPlaylistDialog.makePresent(itemId) showPlaylistDialog.makePresent(itemId)
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
), ),
) )
if (showWatchConfirmation) { if (showWatchConfirmation) {
@ -283,6 +313,19 @@ fun SeriesDetails(
elevation = 3.dp, 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 private const val HEADER_ROW = 0
@ -305,6 +348,7 @@ fun SeriesDetailsContent(
discovered: List<DiscoverItem>, discovered: List<DiscoverItem>,
played: Boolean, played: Boolean,
favorite: Boolean, favorite: Boolean,
canDelete: Boolean,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> Unit, onClickPerson: (Person) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit,
@ -316,6 +360,8 @@ fun SeriesDetailsContent(
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions, moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit,
discoverSeries: DiscoverItem?,
onClickDiscoverSeries: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current 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 { item {
@ -533,6 +615,7 @@ fun SeriesDetailsContent(
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
actions = moreActions, actions = moreActions,
canDelete = false,
) )
moreDialog = moreDialog =
DialogParams( DialogParams(
@ -621,7 +704,7 @@ fun SeriesDetailsHeader(
) { ) {
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
dto.genres?.letNotEmpty { 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 -> dto.overview?.let { overview ->
OverviewText( OverviewText(
@ -638,9 +721,11 @@ fun SeriesDetailsHeader(
fun buildDialogForSeason( fun buildDialogForSeason(
context: Context, context: Context,
s: BaseItem, s: BaseItem,
canDelete: Boolean,
onClickItem: (BaseItem) -> Unit, onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit, markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit,
onClickDelete: (BaseItem) -> Unit,
): DialogParams { ): DialogParams {
val items = val items =
buildList { buildList {
@ -679,6 +764,17 @@ fun buildDialogForSeason(
onClickPlay.invoke(true) 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( return DialogParams(
title = s.name ?: context.getString(R.string.tv_season), title = s.name ?: context.getString(R.string.tv_season),

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester 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.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage 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.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems 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.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -89,6 +93,7 @@ fun SeriesOverview(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(), playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() } val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() }
@ -118,20 +123,7 @@ fun SeriesOverview(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt() var rowFocused by rememberInt()
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
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)
}
}
}
}
}
LaunchedEffect(position, episodes) { LaunchedEffect(position, episodes) {
val focusedEpisode = val focusedEpisode =
@ -146,6 +138,13 @@ fun SeriesOverview(
} }
val chosenStreams by viewModel.chosenStreams.observeAsState(null) val chosenStreams by viewModel.chosenStreams.observeAsState(null)
val preferredSubtitleLanguage =
viewModel.serverRepository.currentUserDto
.observeAsState()
.value
?.configuration
?.subtitleLanguagePreference
when (val state = loading) { when (val state = loading) {
is LoadingState.Error -> { is LoadingState.Error -> {
ErrorMessage(state, modifier) ErrorMessage(state, modifier)
@ -177,7 +176,7 @@ fun SeriesOverview(
} }
} }
fun buildMoreForEpisode( suspend fun buildMoreForEpisode(
ep: BaseItem, ep: BaseItem,
chosenStreams: ChosenStreams?, chosenStreams: ChosenStreams?,
fromLongClick: Boolean, fromLongClick: Boolean,
@ -194,6 +193,7 @@ fun SeriesOverview(
seriesId = series.id, seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete(ep),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel::navigateTo, navigateTo = viewModel::navigateTo,
@ -216,6 +216,9 @@ fun SeriesOverview(
showPlaylistDialog = it showPlaylistDialog = it
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
), ),
onChooseVersion = { onChooseVersion = {
chooseVersion = chooseVersion =
@ -256,6 +259,7 @@ fun SeriesOverview(
type, type,
) )
}, },
preferredSubtitleLanguage = preferredSubtitleLanguage,
) )
} }
}, },
@ -315,7 +319,9 @@ fun SeriesOverview(
) )
}, },
onLongClick = { ep -> onLongClick = { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, true) moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
}
}, },
playOnClick = { resume -> playOnClick = { resume ->
rowFocused = EPISODE_ROW rowFocused = EPISODE_ROW
@ -343,11 +349,14 @@ fun SeriesOverview(
}, },
moreOnClick = { moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, false) moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
} }
}
}, },
overviewOnClick = { overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { episodeList?.getOrNull(position.episodeRowIndex)?.let {
scope.launchDefault {
overviewDialog = overviewDialog =
ItemDetailsDialogInfo( ItemDetailsDialogInfo(
title = it.name ?: context.getString(R.string.unknown), title = it.name ?: context.getString(R.string.unknown),
@ -356,6 +365,7 @@ fun SeriesOverview(
files = it.data.mediaSources.orEmpty(), files = it.data.mediaSources.orEmpty(),
) )
} }
}
}, },
personOnClick = { personOnClick = {
rowFocused = rowFocused =
@ -367,6 +377,8 @@ fun SeriesOverview(
), ),
) )
}, },
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
deleteOnClick = { showDeleteDialog = it },
modifier = modifier, modifier = modifier,
) )
} }
@ -420,6 +432,17 @@ fun SeriesOverview(
elevation = 3.dp, 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 private const val EPISODE_ROW = 0

View file

@ -88,6 +88,8 @@ fun SeriesOverviewContent(
moreOnClick: () -> Unit, moreOnClick: () -> Unit,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
personOnClick: (Person) -> Unit, personOnClick: (Person) -> Unit,
canDelete: (BaseItem) -> Boolean,
deleteOnClick: (BaseItem) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -134,7 +136,7 @@ fun SeriesOverviewContent(
.onFocusChanged { pageHasFocus = it.hasFocus }, .onFocusChanged { pageHasFocus = it.hasFocus },
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier =
Modifier Modifier
.focusGroup() .focusGroup()
@ -159,9 +161,10 @@ fun SeriesOverviewContent(
Modifier Modifier
.focusRequester(tabRowFocusRequester) .focusRequester(tabRowFocusRequester)
.padding(paddingValues) .padding(paddingValues)
.padding(bottom = 4.dp)
.fillMaxWidth(), .fillMaxWidth(),
) )
SeriesName(series.name, Modifier) SeriesName(series.name, Modifier.padding(start = 8.dp))
FocusedEpisodeHeader( FocusedEpisodeHeader(
preferences = preferences, preferences = preferences,
ep = focusedEpisode, ep = focusedEpisode,
@ -266,6 +269,7 @@ fun SeriesOverviewContent(
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
cardHeight = 120.dp, cardHeight = 120.dp,
useSeriesForPrimary = false,
) )
} }
} }
@ -292,10 +296,12 @@ fun SeriesOverviewContent(
} }
} }
}, },
canDelete = canDelete.invoke(ep),
deleteOnClick = { deleteOnClick.invoke(ep) },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .padding(top = 4.dp)
.padding(start = 16.dp), .fillMaxWidth(),
) )
} }
} }

View file

@ -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.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer 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.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager 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.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites 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.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt 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.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt import com.github.damontecres.wholphin.ui.lt
@ -53,6 +57,10 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -90,6 +98,7 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val seerrService: SeerrService, private val seerrService: SeerrService,
private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID, @Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType, @Assisted val seriesPageType: SeriesPageType,
@ -111,9 +120,11 @@ class SeriesViewModel
val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf()) val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>() val similar = MutableLiveData<List<BaseItem>>()
val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -127,6 +138,7 @@ class SeriesViewModel
Timber.v("Start") Timber.v("Start")
addCloseable { themeSongPlayer.stop() } addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId) val item = fetchItem(seriesId)
canDeleteSeries.update { mediaManagementService.canDelete(item) }
backdropService.submit(item) backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@ -222,7 +234,39 @@ class SeriesViewModel
val results = seerrService.similar(item).orEmpty() val results = seerrService.similar(item).orEmpty()
discovered.update { results } 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) { if (seriesPageType == SeriesPageType.DETAILS) {
listOf( listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
) )
} else { } else {
null listOf(
ItemFields.CAN_DELETE,
)
}, },
) )
val pager = val pager =
@ -300,6 +347,7 @@ class SeriesViewModel
ItemFields.OVERVIEW, ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING, ItemFields.CUSTOM_RATING,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
), ),
) )
Timber.v( Timber.v(
@ -341,12 +389,6 @@ class SeriesViewModel
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@SeriesViewModel.episodes.value = episodes 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 api.userLibraryApi
.getItem(item.id) .getItem(item.id)
.content.people .content.people
?.map { Person.fromDto(it, api) } ?.map { Person.fromDto(context, it, api) }
.orEmpty() .orEmpty()
PeopleInItem(item.id, list) PeopleInItem(item.id, list)
@ -551,6 +593,64 @@ class SeriesViewModel
lookUpChosenTracks(item.id, item) 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 { sealed interface EpisodeList {

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -60,8 +58,8 @@ fun DiscoverPage(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -85,13 +85,13 @@ class SeerrRequestsViewModel
seerrService.api.moviesApi seerrService.api.moviesApi
.movieMovieIdGet( .movieMovieIdGet(
movieId = request.media.tmdbId, movieId = request.media.tmdbId,
).let { DiscoverItem(it) } ).let { seerrService.createDiscoverItem(it) }
} }
SeerrItemType.TV -> { SeerrItemType.TV -> {
seerrService.api.tvApi seerrService.api.tvApi
.tvTvIdGet(tvId = request.media.tmdbId) .tvTvIdGet(tvId = request.media.tmdbId)
.let { DiscoverItem(it) } .let { seerrService.createDiscoverItem(it) }
} }
SeerrItemType.PERSON -> { SeerrItemType.PERSON -> {

View file

@ -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.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress 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.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.EpisodeName 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.FocusableItemRow
import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails 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.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
@ -116,6 +118,7 @@ fun HomePage(
LoadingState.Success -> { LoadingState.Success -> {
var dialog by remember { mutableStateOf<DialogParams?>(null) } var dialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var position by rememberPosition() var position by rememberPosition()
HomePageContent( HomePageContent(
@ -136,6 +139,7 @@ fun HomePage(
playbackPosition = item.playbackPosition, playbackPosition = item.playbackPosition,
watched = item.played, watched = item.played,
favorite = item.favorite, favorite = item.favorite,
canDelete = viewModel.canDelete(item, preferences.appPreferences),
actions = actions =
MoreDialogActions( MoreDialogActions(
navigateTo = viewModel.navigationManager::navigateTo, navigateTo = viewModel.navigationManager::navigateTo,
@ -150,6 +154,9 @@ fun HomePage(
showPlaylistDialog = itemId showPlaylistDialog = itemId
}, },
onSendMediaInfo = viewModel.mediaReportService::sendReportFor, onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = RowColumnItem(position, item)
},
), ),
) )
dialog = dialog =
@ -190,6 +197,16 @@ fun HomePage(
elevation = 3.dp, 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, onLongClick = onLongClick,
modifier = modifier, modifier = modifier,
cardHeight = viewOptions.heightDp.dp, cardHeight = viewOptions.heightDp.dp,
useSeriesForPrimary = viewOptions.useSeries,
) )
} else { } else {
BannerCard( BannerCard(
@ -543,6 +561,7 @@ fun HomePageCardContent(
modifier = modifier, modifier = modifier,
interactionSource = null, interactionSource = null,
cardHeight = viewOptions.heightDp.dp, cardHeight = viewOptions.heightDp.dp,
useSeriesForPrimary = viewOptions.useSeries,
) )
} }
} }

View file

@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig 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.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DatePlayedService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomePageResolvedSettings
import com.github.damontecres.wholphin.services.HomeSettingsService 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.MediaReportService
import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavDrawerService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.UserPreferencesService 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.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.launchIO
import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
@ -52,6 +57,7 @@ class HomeViewModel
private val datePlayedService: DatePlayedService, private val datePlayedService: DatePlayedService,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HomeState.EMPTY) private val _state = MutableStateFlow(HomeState.EMPTY)
val state: StateFlow<HomeState> = _state val state: StateFlow<HomeState> = _state
@ -78,6 +84,8 @@ class HomeViewModel
// Refreshing if a load has already occurred and the rows haven't significantly changed // Refreshing if a load has already occurred and the rows haven't significantly changed
val refresh = val refresh =
state.loadingState == LoadingState.Success && state.settings == settings 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) val semaphore = Semaphore(4)
@ -102,6 +110,7 @@ class HomeViewModel
userDto = userDto, userDto = userDto,
libraries = libraries, libraries = libraries,
limit = prefs.maxItemsPerRow, limit = prefs.maxItemsPerRow,
isRefresh = refresh,
) )
} catch (ex: Exception) { } catch (ex: Exception) {
Timber.e(ex, "Error on row %s", row) Timber.e(ex, "Error on row %s", row)
@ -186,6 +195,36 @@ class HomeViewModel
backdropService.submit(item) 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( data class HomeState(

View file

@ -165,7 +165,7 @@ class SearchViewModel
val results = val results =
seerrService seerrService
.search(query) .search(query)
.map { DiscoverItem(it) } .map { seerrService.createDiscoverItem(it) }
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV } .filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results)) seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
} }

View file

@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale
import com.github.damontecres.wholphin.ui.AspectRatio 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.components.ViewOptionImageType
import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.tryRequestFocus
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
@ -81,7 +80,7 @@ data class HomeRowPresets(
contentScale = PrefContentScale.FIT, contentScale = PrefContentScale.FIT,
), ),
liveTv = HomeRowViewOptions.liveTvDefault, 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 height = 148
val epHeight = 100 val epHeight = 100
HomeRowPresets( HomeRowPresets(
@ -132,6 +131,7 @@ data class HomeRowPresets(
heightDp = epHeight, heightDp = epHeight,
imageType = ViewOptionImageType.THUMB, imageType = ViewOptionImageType.THUMB,
aspectRatio = AspectRatio.WIDE, aspectRatio = AspectRatio.WIDE,
useSeries = true,
episodeImageType = ViewOptionImageType.THUMB, episodeImageType = ViewOptionImageType.THUMB,
episodeAspectRatio = AspectRatio.WIDE, episodeAspectRatio = AspectRatio.WIDE,
), ),
@ -164,6 +164,50 @@ data class HomeRowPresets(
genreSize = epHeight, 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, modifier: Modifier = Modifier,
) { ) {
val presets = val presets =
remember {
listOf( listOf(
"Wholphin Default", stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault,
"Wholphin Compact", stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact,
"Thumbnails", 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() } } val focusRequesters = remember { List(presets.size) { FocusRequester() } }
LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
Column(modifier = modifier) { Column(modifier = modifier) {
@ -192,16 +236,12 @@ fun HomeRowPresetsContent(
.fillMaxHeight() .fillMaxHeight()
.focusRestorer(focusRequesters[0]), .focusRestorer(focusRequesters[0]),
) { ) {
itemsIndexed(presets) { index, title -> itemsIndexed(presets) { index, (title, preset) ->
HomeSettingsListItem( HomeSettingsListItem(
selected = false, selected = false,
headlineText = title, headlineText = title,
onClick = { onClick = {
when (index) { onApply.invoke(preset)
0 -> onApply.invoke(HomeRowPresets.WholphinDefault)
1 -> onApply.invoke(HomeRowPresets.WholphinCompact)
2 -> onApply.invoke(HomeRowPresets.Thumbnails)
}
}, },
modifier = Modifier.focusRequester(focusRequesters[index]), modifier = Modifier.focusRequester(focusRequesters[index]),
) )

View file

@ -240,6 +240,7 @@ fun HomeSettingsPage(
onClick = { type -> onClick = { type ->
addRow { viewModel.addFavoriteRow(type) } addRow { viewModel.addFavoriteRow(type) }
}, },
modifier = destModifier,
) )
} }

View file

@ -137,7 +137,7 @@ fun HomeSettingsRowList(
position = 2 position = 2
onClickPresets.invoke() onClickPresets.invoke()
}, },
modifier = Modifier.focusRequester(focusRequesters[1]), modifier = Modifier.focusRequester(focusRequesters[2]),
) )
} }
item { item {

View file

@ -133,6 +133,7 @@ class HomeSettingsViewModel
userDto = userDto, userDto = userDto,
libraries = state.libraries, libraries = state.libraries,
limit = limit, limit = limit,
isRefresh = false,
) )
} }
} }

View file

@ -55,8 +55,8 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs) // Top scrim configuration for text readability (clock, season tabs)
private const val TOP_SCRIM_ALPHA = 0.55f 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_END_FRACTION = 0.25f // Fraction of backdrop image height
@HiltViewModel @HiltViewModel
class ApplicationContentViewModel class ApplicationContentViewModel

View file

@ -121,7 +121,9 @@ fun DestinationContent(
) )
} }
BaseItemKind.VIDEO -> { BaseItemKind.VIDEO,
BaseItemKind.MUSIC_VIDEO,
-> {
// TODO Use VideoDetails // TODO Use VideoDetails
MovieDetails( MovieDetails(
preferences, preferences,
@ -207,7 +209,7 @@ fun DestinationContent(
CollectionFolderPhotoAlbum( CollectionFolderPhotoAlbum(
preferences = preferences, preferences = preferences,
itemId = destination.itemId, itemId = destination.itemId,
recursive = true, recursive = false,
modifier = modifier, modifier = modifier,
) )
} }
@ -385,10 +387,19 @@ fun CollectionFolder(
} }
CollectionType.HOMEVIDEOS, CollectionType.HOMEVIDEOS,
CollectionType.PHOTOS,
-> {
CollectionFolderPhotoAlbum(
preferences = preferences,
itemId = destination.itemId,
recursive = recursiveOverride ?: false,
modifier = modifier,
)
}
CollectionType.MUSICVIDEOS, CollectionType.MUSICVIDEOS,
CollectionType.MUSIC, CollectionType.MUSIC,
CollectionType.BOOKS, CollectionType.BOOKS,
CollectionType.PHOTOS,
-> { -> {
CollectionFolderGeneric( CollectionFolderGeneric(
preferences, preferences,

View file

@ -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)
}
}
}

View file

@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
.padding(PaddingValues(24.dp)), .padding(PaddingValues(24.dp)),
) { ) {
Text( Text(
text = "Search & download subtitles", text = stringResource(R.string.search_and_download_subtitles),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "Language", text = stringResource(R.string.language),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )
@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
} }
if (dialogItems.isEmpty()) { if (dialogItems.isEmpty()) {
Text( Text(
text = "No remote subtitles were found", text = stringResource(R.string.no_subtitles_found),
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
) )

View file

@ -1,20 +1,22 @@
package com.github.damontecres.wholphin.ui.playback package com.github.damontecres.wholphin.ui.playback
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale
import org.jellyfin.sdk.model.api.BaseItemKind 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 playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
val playbackScaleOptions = val playbackScaleOptions =
mapOf( mapOf(
ContentScale.Fit to "Fit", ContentScale.Fit to R.string.content_scale_fit,
ContentScale.None to "None", ContentScale.None to R.string.none,
ContentScale.Crop to "Crop", ContentScale.Crop to R.string.content_scale_crop,
// ContentScale.Inside to "Inside", // ContentScale.Inside to "Inside",
ContentScale.FillBounds to "Fill", ContentScale.FillBounds to R.string.content_scale_fill,
ContentScale.FillWidth to "Fill Width", ContentScale.FillWidth to R.string.content_scale_fill_width,
ContentScale.FillHeight to "Fill Height", ContentScale.FillHeight to R.string.content_scale_fill_height,
) )
val PrefContentScale.scale: ContentScale val PrefContentScale.scale: ContentScale
@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
BaseItemKind.YEAR, BaseItemKind.YEAR,
-> false -> 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
}

View file

@ -8,7 +8,6 @@ import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.lazy.itemsIndexed
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -37,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged 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.Button
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
import com.github.damontecres.wholphin.ui.components.TextButton 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.seekBack
import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.seekForward
import com.github.damontecres.wholphin.ui.skipStringRes import com.github.damontecres.wholphin.ui.skipStringRes
@ -470,6 +468,14 @@ fun <T> BottomDialog(
gravity: Int, gravity: Int,
currentChoice: BottomDialogItem<T>? = null, currentChoice: BottomDialogItem<T>? = null,
) { ) {
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
if (currentChoice != null) {
LaunchedEffect(Unit) {
choices.indexOfFirstOrNull { it == currentChoice }?.let {
focusRequesters.getOrNull(it)?.tryRequestFocus()
}
}
}
// TODO enforcing a width ends up ignore the gravity // TODO enforcing a width ends up ignore the gravity
Dialog( Dialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -504,6 +510,7 @@ fun <T> BottomDialog(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
ListItem( ListItem(
selected = choice == currentChoice, selected = choice == currentChoice,
enabled = choice.enabled,
onClick = { onClick = {
onDismissRequest() onDismissRequest()
onSelectChoice(index, choice) onSelectChoice(index, choice)
@ -524,6 +531,7 @@ fun <T> BottomDialog(
} }
}, },
interactionSource = interactionSource, interactionSource = interactionSource,
modifier = Modifier.focusRequester(focusRequesters[index]),
) )
} }
} }
@ -539,6 +547,7 @@ data class BottomDialogItem<T>(
val data: T, val data: T,
val headline: String, val headline: String,
val supporting: String?, val supporting: String?,
val enabled: Boolean = true,
) )
@PreviewTvSpec @PreviewTvSpec

Some files were not shown because too many files have changed in this diff Show more