mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Merge branch 'main' into develop/ac3-test
This commit is contained in:
commit
a674867656
166 changed files with 8463 additions and 1668 deletions
|
|
@ -44,23 +44,67 @@ android {
|
|||
targetSdk = 36
|
||||
versionCode = gitTags.trim().lines().size
|
||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
if (shouldSign) {
|
||||
create("ci") {
|
||||
file("ci.keystore").writeBytes(
|
||||
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
||||
)
|
||||
keyAlias = System.getenv("KEY_ALIAS")
|
||||
keyPassword = System.getenv("KEY_PASSWORD")
|
||||
storePassword = System.getenv("KEY_STORE_PASSWORD")
|
||||
storeFile = file("ci.keystore")
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
enableV4Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
isDebuggable = false
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
} else {
|
||||
val localPropertiesFile = project.rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
val properties = Properties()
|
||||
properties.load(localPropertiesFile.inputStream())
|
||||
val signingConfigName = properties["release.signing.config"]?.toString()
|
||||
if (signingConfigName != null) {
|
||||
signingConfig = signingConfigs.getByName(signingConfigName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
isDebuggable = true
|
||||
applicationIdSuffix = ".debug"
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
|
||||
val outputFileName =
|
||||
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
|
|
@ -81,63 +125,6 @@ android {
|
|||
room {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
signingConfigs {
|
||||
if (shouldSign) {
|
||||
create("ci") {
|
||||
file("ci.keystore").writeBytes(
|
||||
Base64.getDecoder().decode(System.getenv("SIGNING_KEY")),
|
||||
)
|
||||
keyAlias = System.getenv("KEY_ALIAS")
|
||||
keyPassword = System.getenv("KEY_PASSWORD")
|
||||
storePassword = System.getenv("KEY_STORE_PASSWORD")
|
||||
storeFile = file("ci.keystore")
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
enableV3Signing = true
|
||||
enableV4Signing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
} else {
|
||||
val localPropertiesFile = project.rootProject.file("local.properties")
|
||||
if (localPropertiesFile.exists()) {
|
||||
val properties = Properties()
|
||||
properties.load(localPropertiesFile.inputStream())
|
||||
val signingConfigName = properties["release.signing.config"]?.toString()
|
||||
if (signingConfigName != null) {
|
||||
signingConfig = signingConfigs.getByName(signingConfigName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug {
|
||||
if (shouldSign) {
|
||||
signingConfig = signingConfigs.getByName("ci")
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" }
|
||||
val outputFileName =
|
||||
"Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk"
|
||||
output.outputFileName = outputFileName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
|
|
@ -153,6 +140,12 @@ android {
|
|||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||
}
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protobuf {
|
||||
|
|
@ -267,6 +260,7 @@ dependencies {
|
|||
implementation(libs.androidx.room.testing)
|
||||
implementation(libs.androidx.palette.ktx)
|
||||
implementation(libs.androidx.media3.effect)
|
||||
implementation(libs.androidx.runner)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
ksp(libs.androidx.hilt.compiler)
|
||||
|
|
@ -305,4 +299,9 @@ dependencies {
|
|||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.androidx.core.testing)
|
||||
testImplementation(libs.robolectric)
|
||||
testImplementation(libs.hilt.android.testing)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(libs.mockk.android)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.androidx.ui.test.manifest)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.github.damontecres.wholphin.test
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class TestActivity : ComponentActivity()
|
||||
|
|
@ -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)
|
||||
}
|
||||
94
app/src/debug/AndroidManifest.xml
Normal file
94
app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -68,6 +68,17 @@
|
|||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
tools:node="remove" />
|
||||
|
||||
<service
|
||||
android:name=".WholphinDreamService"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:permission="android.permission.BIND_DREAM_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.dreams.DreamService" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin
|
|||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
|
|
@ -10,8 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -19,28 +18,16 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Surface
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||
|
|
@ -49,6 +36,7 @@ import com.github.damontecres.wholphin.services.ImageUrlService
|
|||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||
import com.github.damontecres.wholphin.services.SetupDestination
|
||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||
|
|
@ -61,11 +49,9 @@ import com.github.damontecres.wholphin.ui.CoilConfig
|
|||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||
|
|
@ -74,8 +60,13 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
|
|
@ -102,9 +93,6 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var updateChecker: UpdateChecker
|
||||
|
||||
@Inject
|
||||
lateinit var appUpgradeHandler: AppUpgradeHandler
|
||||
|
||||
@Inject
|
||||
lateinit var playbackLifecycleObserver: PlaybackLifecycleObserver
|
||||
|
||||
|
|
@ -123,6 +111,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var suggestionsSchedulerService: SuggestionsSchedulerService
|
||||
|
||||
@Inject
|
||||
lateinit var backdropService: BackdropService
|
||||
|
||||
// Note: unused but injected to ensure it is created
|
||||
@Inject
|
||||
lateinit var serverEventListener: ServerEventListener
|
||||
|
|
@ -131,6 +122,9 @@ class MainActivity : AppCompatActivity() {
|
|||
@Inject
|
||||
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
|
||||
|
||||
@Inject
|
||||
lateinit var screensaverService: ScreensaverService
|
||||
|
||||
private var signInAuto = true
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
|
|
@ -139,11 +133,7 @@ class MainActivity : AppCompatActivity() {
|
|||
instance = this
|
||||
Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}")
|
||||
lifecycle.addObserver(playbackLifecycleObserver)
|
||||
if (savedInstanceState == null) {
|
||||
lifecycleScope.launchIO {
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.serverRepository.currentUser.observe(this) { user ->
|
||||
if (user?.hasPin == true) {
|
||||
window?.setFlags(
|
||||
|
|
@ -154,6 +144,20 @@ class MainActivity : AppCompatActivity() {
|
|||
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
}
|
||||
screensaverService.keepScreenOn
|
||||
.onEach { keepScreenOn ->
|
||||
Timber.v("keepScreenOn: %s", keepScreenOn)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (keepScreenOn) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error with keepScreenOn")
|
||||
}.launchIn(lifecycleScope)
|
||||
|
||||
viewModel.appStart()
|
||||
setContent {
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(null)
|
||||
|
|
@ -201,115 +205,19 @@ class MainActivity : AppCompatActivity() {
|
|||
true,
|
||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
shape = RectangleShape,
|
||||
) {
|
||||
// val backStack = rememberNavBackStack(SetupDestination.Loading)
|
||||
// setupNavigationManager.backStack = backStack
|
||||
val backStack = setupNavigationManager.backStack
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryDecorators =
|
||||
listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
entryProvider = { key ->
|
||||
key as SetupDestination
|
||||
NavEntry(key) {
|
||||
when (key) {
|
||||
SetupDestination.Loading -> {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SetupDestination.ServerList -> {
|
||||
SwitchServerContent(Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
is SetupDestination.UserList -> {
|
||||
SwitchUserContent(
|
||||
currentServer = key.server,
|
||||
Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
|
||||
is SetupDestination.AppContent -> {
|
||||
val current = key.current
|
||||
ProvideLocalClock {
|
||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
updateChecker.maybeShowUpdateToast(
|
||||
appPreferences.updateUrl,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Exception during update check",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val appPreferences by userPreferencesDataStore.data.collectAsState(
|
||||
appPreferences,
|
||||
)
|
||||
val preferences =
|
||||
remember(appPreferences) {
|
||||
UserPreferences(appPreferences)
|
||||
}
|
||||
var showContent by remember {
|
||||
mutableStateOf(true)
|
||||
}
|
||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
|
||||
if (!preferences.appPreferences.signInAutomatically) {
|
||||
showContent = false
|
||||
}
|
||||
}
|
||||
|
||||
if (showContent) {
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination)
|
||||
}
|
||||
ApplicationContent(
|
||||
user = current.user,
|
||||
server = current.server,
|
||||
startDestination =
|
||||
requestedDestination
|
||||
?: Destination.Home(),
|
||||
navigationManager = navigationManager,
|
||||
preferences = preferences,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.size(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.border,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ProvideLocalClock {
|
||||
val requestedDestination =
|
||||
remember(intent) {
|
||||
intent?.let(::extractDestination) ?: Destination.Home()
|
||||
}
|
||||
MainContent(
|
||||
backStack = setupNavigationManager.backStack,
|
||||
navigationManager = navigationManager,
|
||||
appPreferences = appPreferences,
|
||||
backdropService = backdropService,
|
||||
screensaverService = screensaverService,
|
||||
requestedDestination = requestedDestination,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -318,11 +226,22 @@ class MainActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (screensaverService.state.value.show) {
|
||||
screensaverService.stop(false)
|
||||
screensaverService.pulse()
|
||||
return true
|
||||
} else {
|
||||
screensaverService.pulse()
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
Timber.d("onResume")
|
||||
lifecycleScope.launchIO {
|
||||
appUpgradeHandler.run()
|
||||
lifecycleScope.launchDefault {
|
||||
screensaverService.pulse()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +254,7 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStop() {
|
||||
super.onStop()
|
||||
Timber.d("onStop")
|
||||
screensaverService.stop(true)
|
||||
tvProviderSchedulerService.launchOneTimeRefresh()
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +266,22 @@ class MainActivity : AppCompatActivity() {
|
|||
override fun onStart() {
|
||||
super.onStart()
|
||||
Timber.d("onStart")
|
||||
|
||||
lifecycleScope.launchDefault {
|
||||
val appPreferences = userPreferencesDataStore.data.first()
|
||||
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||
try {
|
||||
updateChecker.maybeShowUpdateToast(
|
||||
appPreferences.updateUrl,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Exception during update check",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
|
|
@ -440,10 +376,13 @@ class MainActivityViewModel
|
|||
private val navigationManager: SetupNavigationManager,
|
||||
private val deviceProfileService: DeviceProfileService,
|
||||
private val backdropService: BackdropService,
|
||||
private val appUpgradeHandler: AppUpgradeHandler,
|
||||
) : ViewModel() {
|
||||
fun appStart() {
|
||||
viewModelScope.launchIO {
|
||||
try {
|
||||
appUpgradeHandler.run()
|
||||
appUpgradeHandler.copySubfont(false)
|
||||
val prefs =
|
||||
preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
val userHasPin = serverRepository.currentUser.value?.hasPin == true
|
||||
|
|
|
|||
147
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
147
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
|
|||
import android.app.Application
|
||||
import android.os.Build
|
||||
import android.os.StrictMode
|
||||
import android.os.StrictMode.ThreadPolicy
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composer
|
||||
import androidx.compose.runtime.ExperimentalComposeRuntimeApi
|
||||
|
|
@ -27,16 +26,6 @@ class WholphinApplication :
|
|||
init {
|
||||
instance = this
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
} else {
|
||||
|
|
@ -64,6 +53,23 @@ class WholphinApplication :
|
|||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy
|
||||
.Builder()
|
||||
.detectNetwork()
|
||||
.penaltyLog()
|
||||
.penaltyDeathOnNetwork()
|
||||
.build(),
|
||||
)
|
||||
// StrictMode.setVmPolicy(
|
||||
// StrictMode.VmPolicy
|
||||
// .Builder()
|
||||
// .detectAll()
|
||||
// .penaltyLog()
|
||||
// .build(),
|
||||
// )
|
||||
}
|
||||
OkHttp.initialize(this)
|
||||
initAcra {
|
||||
buildConfigClass = BuildConfig::class.java
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ interface SeerrServerDao {
|
|||
suspend fun deleteUser(
|
||||
serverId: Int,
|
||||
jellyfinUserRowId: Int,
|
||||
)
|
||||
): Int
|
||||
|
||||
suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,12 @@ import androidx.lifecycle.map
|
|||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -41,9 +43,8 @@ class ServerRepository
|
|||
val serverDao: JellyfinServerDao,
|
||||
val apiClient: ApiClient,
|
||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val sharedPreferences = getServerSharedPreferences(context)
|
||||
|
||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||
val current: LiveData<CurrentUser?> = _current
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ class ServerRepository
|
|||
* The current user is removed
|
||||
*/
|
||||
suspend fun addAndChangeServer(server: JellyfinServer) {
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.addOrUpdateServer(server)
|
||||
}
|
||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||
|
|
@ -73,7 +74,7 @@ class ServerRepository
|
|||
server: JellyfinServer,
|
||||
user: JellyfinUser,
|
||||
): CurrentUser? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
if (server.id != user.serverId) {
|
||||
throw IllegalStateException("User is not part of the server")
|
||||
}
|
||||
|
|
@ -107,7 +108,7 @@ class ServerRepository
|
|||
_current.value = CurrentUser(updatedServer, updatedUser)
|
||||
_currentUserDto.value = userDto
|
||||
}
|
||||
sharedPreferences.edit(true) {
|
||||
getServerSharedPreferences(context).edit(true) {
|
||||
putString(SERVER_URL_KEY, updatedServer.url)
|
||||
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
|
||||
}
|
||||
|
|
@ -128,7 +129,7 @@ class ServerRepository
|
|||
return null
|
||||
}
|
||||
val serverAndUsers =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.getServer(serverId)
|
||||
}
|
||||
if (serverAndUsers != null) {
|
||||
|
|
@ -151,7 +152,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +166,7 @@ class ServerRepository
|
|||
suspend fun changeUser(
|
||||
serverUrl: String,
|
||||
authenticationResult: AuthenticationResult,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(ioDispatcher) {
|
||||
val accessToken = authenticationResult.accessToken
|
||||
if (accessToken != null) {
|
||||
val authedUser = authenticationResult.user
|
||||
|
|
@ -215,7 +216,7 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(accessToken = null)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.deleteUser(user.serverId, user.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -235,7 +236,7 @@ class ServerRepository
|
|||
}
|
||||
apiClient.update(baseUrl = null, accessToken = null)
|
||||
}
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
serverDao.deleteServer(server.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -254,7 +255,7 @@ class ServerRepository
|
|||
suspend fun setUserPin(
|
||||
user: JellyfinUser,
|
||||
pin: String?,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
) = withContext(ioDispatcher) {
|
||||
val newUser = user.copy(pin = pin)
|
||||
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
||||
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
||||
|
|
@ -267,7 +268,7 @@ class ServerRepository
|
|||
}
|
||||
|
||||
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(ioDispatcher) {
|
||||
val userId = currentUser.value?.id
|
||||
if (userId == null) {
|
||||
Timber.e("No user logged in for Quick Connect authorization")
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
|||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
|
|
@ -33,6 +34,7 @@ data class BaseItem(
|
|||
val data: BaseItemDto,
|
||||
val useSeriesForPrimary: Boolean,
|
||||
val imageUrlOverride: String? = null,
|
||||
val destinationOverride: Destination? = null,
|
||||
) : CardGridItem {
|
||||
val id get() = data.id
|
||||
|
||||
|
|
@ -70,6 +72,8 @@ data class BaseItem(
|
|||
}
|
||||
}
|
||||
|
||||
val canDelete: Boolean get() = data.canDelete == true
|
||||
|
||||
@Transient
|
||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||
|
||||
|
|
@ -93,7 +97,11 @@ data class BaseItem(
|
|||
data.indexNumber?.let { "E$it" }
|
||||
?: data.premiereDate?.let(::formatDateTime),
|
||||
episodeUnplayedCornerText =
|
||||
if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) {
|
||||
if (type == BaseItemKind.SERIES ||
|
||||
type == BaseItemKind.SEASON ||
|
||||
type == BaseItemKind.EPISODE ||
|
||||
type == BaseItemKind.BOX_SET
|
||||
) {
|
||||
data.indexNumber?.let { "E$it" }
|
||||
?: data.userData
|
||||
?.unplayedItemCount
|
||||
|
|
@ -166,6 +174,7 @@ data class BaseItem(
|
|||
}?.toIntOrNull()
|
||||
|
||||
fun destination(index: Int? = null): Destination {
|
||||
if (destinationOverride != null) return destinationOverride
|
||||
val result =
|
||||
// Redirect episodes & seasons to their series if possible
|
||||
when (type) {
|
||||
|
|
@ -234,3 +243,28 @@ data class BaseItemUi(
|
|||
val episodeUnplayedCornerText: String?,
|
||||
val quickDetails: AnnotatedString,
|
||||
)
|
||||
|
||||
fun createGenreDestination(
|
||||
genreId: UUID,
|
||||
genreName: String,
|
||||
parentId: UUID,
|
||||
parentName: String?,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
) = Destination.FilteredCollection(
|
||||
itemId = parentId,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
genreName,
|
||||
parentName,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genreId),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ data class Chapter(
|
|||
)
|
||||
},
|
||||
)
|
||||
}.orEmpty()
|
||||
}?.sortedBy { it.position }
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,17 @@
|
|||
|
||||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.services.SeerrSearchResult
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.toLocalDate
|
||||
import com.github.damontecres.wholphin.util.LocalDateSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import java.time.LocalDate
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -74,6 +66,7 @@ enum class SeerrAvailability(
|
|||
/**
|
||||
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
|
||||
*/
|
||||
@Stable
|
||||
@Serializable
|
||||
data class DiscoverItem(
|
||||
val id: Int,
|
||||
|
|
@ -83,17 +76,14 @@ data class DiscoverItem(
|
|||
val overview: String?,
|
||||
val availability: SeerrAvailability,
|
||||
@Serializable(LocalDateSerializer::class) val releaseDate: LocalDate?,
|
||||
val posterPath: String?,
|
||||
val backdropPath: String?,
|
||||
val posterUrl: String?,
|
||||
val backDropUrl: String?,
|
||||
val jellyfinItemId: UUID?,
|
||||
) : CardGridItem {
|
||||
override val gridId: String get() = id.toString()
|
||||
override val playable: Boolean = false
|
||||
override val sortName: String get() = title ?: ""
|
||||
|
||||
val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h1080_multi_faces$it" }
|
||||
val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }
|
||||
|
||||
val destination: Destination
|
||||
get() {
|
||||
val jfType =
|
||||
|
|
@ -112,103 +102,6 @@ data class DiscoverItem(
|
|||
Destination.DiscoveredItem(this)
|
||||
}
|
||||
}
|
||||
|
||||
constructor(movie: MovieResult) : this(
|
||||
id = movie.id,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterPath = movie.posterPath,
|
||||
backdropPath = movie.backdropPath,
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(movie: MovieDetails) : this(
|
||||
id = movie.id ?: -1,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterPath = movie.posterPath,
|
||||
backdropPath = movie.backdropPath,
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(tv: TvResult) : this(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterPath = tv.posterPath,
|
||||
backdropPath = tv.backdropPath,
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(tv: TvDetails) : this(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterPath = tv.posterPath,
|
||||
backdropPath = tv.backdropPath,
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(search: SeerrSearchResult) : this(
|
||||
id = search.id,
|
||||
type = SeerrItemType.fromString(search.mediaType),
|
||||
title = search.title ?: search.name,
|
||||
subtitle = null,
|
||||
overview = search.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(search.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||
posterPath = search.posterPath,
|
||||
backdropPath = search.backdropPath,
|
||||
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(credit: CreditCast) : this(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.character,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterPath = credit.posterPath ?: credit.profilePath,
|
||||
backdropPath = credit.backdropPath,
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
constructor(credit: CreditCrew) : this(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.job,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterPath = credit.posterPath ?: credit.profilePath,
|
||||
backdropPath = credit.backdropPath,
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
|
||||
data class DiscoverRating(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -19,19 +23,21 @@ data class Person(
|
|||
) {
|
||||
companion object {
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
api: ApiClient,
|
||||
): Person =
|
||||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = false,
|
||||
)
|
||||
|
||||
fun fromDto(
|
||||
context: Context,
|
||||
dto: BaseItemPerson,
|
||||
favorite: Boolean,
|
||||
api: ApiClient,
|
||||
|
|
@ -39,10 +45,51 @@ data class Person(
|
|||
Person(
|
||||
id = dto.id,
|
||||
name = dto.name,
|
||||
role = dto.role,
|
||||
role = personRole(context, dto.role, dto.type),
|
||||
type = dto.type,
|
||||
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
|
||||
favorite = favorite,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun personRole(
|
||||
context: Context,
|
||||
role: String?,
|
||||
type: PersonKind,
|
||||
): String? =
|
||||
if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) {
|
||||
role
|
||||
} else if (role.equals(type.name, ignoreCase = true)) {
|
||||
type.stringRes?.let { context.getString(it) }
|
||||
} else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) {
|
||||
role
|
||||
} else {
|
||||
listOfNotNull(
|
||||
role?.takeIf { it.isNotNullOrBlank() },
|
||||
type.stringRes?.let { context.getString(it) },
|
||||
).takeIf { it.isNotEmpty() }?.joinToString(" - ")
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
private val PersonKind.stringRes: Int?
|
||||
get() =
|
||||
when (this) {
|
||||
PersonKind.UNKNOWN -> R.string.unknown
|
||||
PersonKind.ACTOR -> R.string.actor
|
||||
PersonKind.DIRECTOR -> R.string.director
|
||||
PersonKind.COMPOSER -> R.string.composer
|
||||
PersonKind.WRITER -> R.string.writer
|
||||
PersonKind.GUEST_STAR -> R.string.guest_star
|
||||
PersonKind.PRODUCER -> R.string.producer
|
||||
PersonKind.CONDUCTOR -> R.string.conductor
|
||||
PersonKind.LYRICIST -> R.string.lyricist
|
||||
PersonKind.ARRANGER -> R.string.arranger
|
||||
PersonKind.ENGINEER -> R.string.engineer
|
||||
PersonKind.MIXER -> R.string.mixer
|
||||
PersonKind.REMIXER -> R.string.mixer
|
||||
PersonKind.CREATOR -> R.string.creator
|
||||
PersonKind.ARTIST -> R.string.artist
|
||||
PersonKind.ALBUM_ARTIST -> R.string.artist
|
||||
else -> null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -775,6 +775,18 @@ sealed interface AppPreference<Pref, T> {
|
|||
valueToIndex = { it.number },
|
||||
)
|
||||
|
||||
val ManageMedia =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.show_media_management,
|
||||
defaultValue = false,
|
||||
getter = { it.interfacePreferences.enableMediaManagement },
|
||||
setter = { prefs, value ->
|
||||
prefs.updateInterfacePreferences { enableMediaManagement = value }
|
||||
},
|
||||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val OneClickPause =
|
||||
AppSwitchPreference<AppPreferences>(
|
||||
title = R.string.one_click_pause,
|
||||
|
|
@ -1031,6 +1043,12 @@ sealed interface AppPreference<Pref, T> {
|
|||
summaryOn = R.string.enabled,
|
||||
summaryOff = R.string.disabled,
|
||||
)
|
||||
|
||||
val ScreensaverSettings =
|
||||
AppDestinationPreference<AppPreferences>(
|
||||
title = R.string.screensaver_settings,
|
||||
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1045,6 +1063,7 @@ val basicPreferences =
|
|||
AppPreference.RememberSelectedTab,
|
||||
AppPreference.SubtitleStyle,
|
||||
AppPreference.ThemeColors,
|
||||
AppPreference.ScreensaverSettings,
|
||||
),
|
||||
),
|
||||
PreferenceGroup(
|
||||
|
|
@ -1138,6 +1157,7 @@ val advancedPreferences =
|
|||
preferences =
|
||||
listOf(
|
||||
AppPreference.ShowClock,
|
||||
AppPreference.ManageMedia,
|
||||
AppPreference.CombineContinueNext,
|
||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||
// AppPreference.NavDrawerSwitchOnFocus,
|
||||
|
|
@ -1236,6 +1256,24 @@ val liveTvPreferences =
|
|||
AppPreference.LiveTvColorCodePrograms,
|
||||
)
|
||||
|
||||
val screensaverPreferences =
|
||||
listOf(
|
||||
PreferenceGroup(
|
||||
title = R.string.screensaver,
|
||||
preferences =
|
||||
listOf(
|
||||
ScreensaverPreference.Enabled,
|
||||
ScreensaverPreference.StartDelay,
|
||||
ScreensaverPreference.Duration,
|
||||
ScreensaverPreference.ShowClock,
|
||||
ScreensaverPreference.Animate,
|
||||
ScreensaverPreference.MaxAge,
|
||||
ScreensaverPreference.ItemTypes,
|
||||
ScreensaverPreference.Start,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
data class AppSwitchPreference<Pref>(
|
||||
@get:StringRes override val title: Int,
|
||||
override val defaultValue: Boolean,
|
||||
|
|
@ -1295,8 +1333,6 @@ data class AppMultiChoicePreference<Pref, T>(
|
|||
override val getter: (prefs: Pref) -> List<T>,
|
||||
override val setter: (prefs: Pref, value: List<T>) -> Pref,
|
||||
@param:StringRes val summary: Int? = null,
|
||||
val toSharedPrefs: (T) -> String,
|
||||
val fromSharedPrefs: (String) -> T?,
|
||||
) : AppPreference<Pref, List<T>>
|
||||
|
||||
data class AppClickablePreference<Pref>(
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
|
|||
import androidx.datastore.core.Serializer
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.google.protobuf.InvalidProtocolBufferException
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import javax.inject.Inject
|
||||
|
|
@ -120,6 +121,20 @@ class AppPreferencesSerializer
|
|||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}.build()
|
||||
|
||||
screensaverPreference =
|
||||
ScreensaverPreferences
|
||||
.newBuilder()
|
||||
.apply {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
showClock = ScreensaverPreference.ShowClock.defaultValue
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}.build()
|
||||
}.build()
|
||||
|
||||
advancedPreferences =
|
||||
|
|
@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
|
|||
photoPreferences = photoPreferences.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences =
|
||||
updateInterfacePreferences {
|
||||
screensaverPreference = screensaverPreference.toBuilder().apply(block).build()
|
||||
}
|
||||
|
||||
fun SubtitlePreferences.Builder.resetSubtitles() {
|
||||
fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
|
||||
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -6,8 +6,10 @@ import androidx.core.content.edit
|
|||
import androidx.datastore.core.DataStore
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
|
||||
import com.github.damontecres.wholphin.preferences.update
|
||||
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
|
||||
|
|
@ -16,11 +18,14 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
|
|||
import com.github.damontecres.wholphin.preferences.updateMpvOptions
|
||||
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
|
||||
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
|
||||
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
|
||||
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.migrateSeerrUrl
|
||||
import com.github.damontecres.wholphin.util.Version
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
|
@ -32,9 +37,14 @@ class AppUpgradeHandler
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val appPreferences: DataStore<AppPreferences>,
|
||||
private val seerrServerDao: SeerrServerDao,
|
||||
) {
|
||||
suspend fun run() {
|
||||
val pkgInfo = WholphinApplication.instance.packageManager.getPackageInfo(WholphinApplication.instance.packageName, 0)
|
||||
val pkgInfo =
|
||||
WholphinApplication.instance.packageManager.getPackageInfo(
|
||||
WholphinApplication.instance.packageName,
|
||||
0,
|
||||
)
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val previousVersion = prefs.getString(VERSION_NAME_CURRENT_KEY, null)
|
||||
val previousVersionCode = prefs.getLong(VERSION_CODE_CURRENT_KEY, -1)
|
||||
|
|
@ -59,14 +69,16 @@ class AppUpgradeHandler
|
|||
try {
|
||||
copySubfont(true)
|
||||
upgradeApp(
|
||||
context,
|
||||
Version.Companion.fromString(previousVersion ?: "0.0.0"),
|
||||
Version.Companion.fromString(newVersion),
|
||||
Version.fromString(previousVersion ?: "0.0.0"),
|
||||
Version.fromString(newVersion),
|
||||
appPreferences,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Exception during app upgrade")
|
||||
}
|
||||
Timber.i("App upgrade complete")
|
||||
} else {
|
||||
Timber.d("No app update needed")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,110 +109,108 @@ class AppUpgradeHandler
|
|||
const val VERSION_NAME_CURRENT_KEY = "version.current.name"
|
||||
const val VERSION_CODE_CURRENT_KEY = "version.current.code"
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun upgradeApp(
|
||||
context: Context,
|
||||
previous: Version,
|
||||
current: Version,
|
||||
appPreferences: DataStore<AppPreferences>,
|
||||
) {
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePlaybackOverrides {
|
||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
showClock = AppPreference.ShowClock.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) {
|
||||
PreferencesViewModel.resetSubtitleSettings(appPreferences)
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateSubtitlePreferences {
|
||||
margin = SubtitleSettings.Margin.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateAdvancedPreferences {
|
||||
if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) {
|
||||
imageDiskCacheSizeBytes =
|
||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||
suspend fun upgradeApp(
|
||||
previous: Version,
|
||||
current: Version,
|
||||
appPreferences: DataStore<AppPreferences>,
|
||||
) {
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.1.0-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePlaybackOverrides {
|
||||
ac3Supported = AppPreference.Ac3Supported.defaultValue
|
||||
downmixStereo = AppPreference.DownMixStereo.defaultValue
|
||||
directPlayAss = AppPreference.DirectPlayAss.defaultValue
|
||||
directPlayPgs = AppPreference.DirectPlayPgs.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = AppPreference.MpvGpuNext.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.update {
|
||||
signInAutomatically = AppPreference.SignInAuto.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateSubtitlePreferences {
|
||||
if (edgeThickness < 1) {
|
||||
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.3-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
navDrawerSwitchOnFocus = AppPreference.NavDrawerSwitchOnFocus.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateLiveTvPreferences {
|
||||
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||
favoriteChannelsAtBeginning =
|
||||
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||
sortByRecentlyWatched =
|
||||
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
|
||||
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = false
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.5-11-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
showClock = AppPreference.ShowClock.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.2.7-1-g0"))) {
|
||||
PreferencesViewModel.resetSubtitleSettings(appPreferences)
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.2-4-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateSubtitlePreferences {
|
||||
margin = SubtitleSettings.Margin.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO temporarily disabled until some MPV bugs are fixed
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateAdvancedPreferences {
|
||||
if (imageDiskCacheSizeBytes < (AppPreference.ImageDiskCacheSize.min * AppPreference.MEGA_BIT)) {
|
||||
imageDiskCacheSizeBytes =
|
||||
AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = AppPreference.MpvGpuNext.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.4-4-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.update {
|
||||
signInAutomatically = AppPreference.SignInAuto.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.5-0-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateSubtitlePreferences {
|
||||
if (edgeThickness < 1) {
|
||||
edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.5-56-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateLiveTvPreferences {
|
||||
showHeader = AppPreference.LiveTvShowHeader.defaultValue
|
||||
favoriteChannelsAtBeginning =
|
||||
AppPreference.LiveTvFavoriteChannelsBeginning.defaultValue
|
||||
sortByRecentlyWatched =
|
||||
AppPreference.LiveTvChannelSortByWatched.defaultValue
|
||||
colorCodePrograms =
|
||||
AppPreference.LiveTvColorCodePrograms.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.3.6-52-g0"))) {
|
||||
if (Build.MODEL.equals("shield android tv", ignoreCase = true)) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO temporarily disabled until some MPV bugs are fixed
|
||||
// if (previous.isEqualOrBefore(Version.fromString("0.4.0-1-g0"))) {
|
||||
// appPreferences.updateData {
|
||||
// it.updatePlaybackPreferences { playerBackend = PlayerBackend.PREFER_MPV }
|
||||
|
|
@ -208,42 +218,67 @@ suspend fun upgradeApp(
|
|||
// showToast(context, context.getString(R.string.upgrade_mpv_toast), Toast.LENGTH_LONG)
|
||||
// }
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = false
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.0-2-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateMpvOptions {
|
||||
useGpuNext = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
subtitlesPreferences =
|
||||
subtitlesPreferences
|
||||
.toBuilder()
|
||||
.apply {
|
||||
imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt()
|
||||
}.build()
|
||||
// Copy current subtitle prefs as HDR ones
|
||||
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateInterfacePreferences {
|
||||
subtitlesPreferences =
|
||||
subtitlesPreferences
|
||||
.toBuilder()
|
||||
.apply {
|
||||
imageSubtitleOpacity =
|
||||
SubtitleSettings.ImageOpacity.defaultValue.toInt()
|
||||
}.build()
|
||||
// Copy current subtitle prefs as HDR ones
|
||||
hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePhotoPreferences {
|
||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updatePhotoPreferences {
|
||||
slideshowDuration = AppPreference.SlideshowDuration.defaultValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateHomePagePreferences {
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateHomePagePreferences {
|
||||
maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.5.3-0-g0"))) {
|
||||
appPreferences.updateData {
|
||||
it.updateScreensaverPreferences {
|
||||
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
|
||||
duration = ScreensaverPreference.DEFAULT_DURATION
|
||||
animate = ScreensaverPreference.Animate.defaultValue
|
||||
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
|
||||
clearItemTypes()
|
||||
addItemTypes(BaseItemKind.MOVIE.serialName)
|
||||
addItemTypes(BaseItemKind.SERIES.serialName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previous.isEqualOrBefore(Version.fromString("0.5.4-0-g0"))) {
|
||||
seerrServerDao.getServers().forEach {
|
||||
val server = it.server
|
||||
seerrServerDao.updateServer(
|
||||
server.copy(url = migrateSeerrUrl(server.url)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ class BackdropService
|
|||
if (item.type == BaseItemKind.GENRE) {
|
||||
item.imageUrlOverride
|
||||
} else {
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!!
|
||||
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
|
||||
}
|
||||
submit(item.id.toString(), imageUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.HomePageSettings
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
|
||||
import com.github.damontecres.wholphin.preferences.HomePagePreferences
|
||||
import com.github.damontecres.wholphin.ui.DefaultItemFields
|
||||
|
|
@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
|
||||
import com.github.damontecres.wholphin.ui.main.settings.Library
|
||||
import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions
|
||||
import com.github.damontecres.wholphin.ui.playback.getTypeFor
|
||||
import com.github.damontecres.wholphin.ui.toBaseItems
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
|
|
@ -567,6 +569,7 @@ class HomeSettingsService
|
|||
userDto: UserDto,
|
||||
libraries: List<Library>,
|
||||
limit: Int = prefs.maxItemsPerRow,
|
||||
isRefresh: Boolean,
|
||||
): HomeRowLoadingState =
|
||||
when (row) {
|
||||
is HomeRowConfig.ContinueWatching -> {
|
||||
|
|
@ -647,25 +650,42 @@ class HomeSettingsService
|
|||
val genreImages =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
scope = scope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genreIds,
|
||||
parentId = row.parentId,
|
||||
includeItemTypes = null,
|
||||
cardWidthPx = null,
|
||||
useCache = isRefresh,
|
||||
)
|
||||
val genres =
|
||||
items.map {
|
||||
BaseItem(it, false, genreImages[it.id])
|
||||
}
|
||||
|
||||
val name =
|
||||
val library =
|
||||
libraries
|
||||
.firstOrNull { it.itemId == row.parentId }
|
||||
?.name
|
||||
|
||||
val title =
|
||||
name?.let { context.getString(R.string.genres_in, it) }
|
||||
library?.name?.let { context.getString(R.string.genres_in, it) }
|
||||
?: context.getString(R.string.genres)
|
||||
val genres =
|
||||
items.map {
|
||||
BaseItem(
|
||||
it,
|
||||
false,
|
||||
genreImages[it.id],
|
||||
createGenreDestination(
|
||||
genreId = it.id,
|
||||
genreName = it.name ?: "",
|
||||
parentId = row.parentId,
|
||||
parentName = library?.name,
|
||||
includeItemTypes =
|
||||
library?.collectionType?.let {
|
||||
getTypeFor(it)?.let {
|
||||
listOf(it)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
Success(
|
||||
title,
|
||||
|
|
|
|||
|
|
@ -28,18 +28,16 @@ class ImageUrlService
|
|||
itemType: BaseItemKind,
|
||||
seriesId: UUID?,
|
||||
useSeriesForPrimary: Boolean,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
imageType: ImageType,
|
||||
imageTags: Map<ImageType, String?>,
|
||||
backdropTags: List<String>,
|
||||
parentThumbId: UUID? = null,
|
||||
parentBackdropId: UUID? = null,
|
||||
backdropTags: List<String> = emptyList(),
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String? =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
-> {
|
||||
ImageType.LOGO -> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
|
|
@ -57,6 +55,27 @@ class ImageUrlService
|
|||
}
|
||||
}
|
||||
|
||||
ImageType.BACKDROP,
|
||||
-> {
|
||||
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
|
||||
getItemImageUrl(
|
||||
itemId = seriesId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else if (backdropTags.isNotEmpty()) {
|
||||
getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
fillWidth = fillWidth,
|
||||
fillHeight = fillHeight,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
ImageType.THUMB -> {
|
||||
if (useSeriesForPrimary && parentThumbId != null &&
|
||||
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,15 +11,19 @@ import com.github.damontecres.wholphin.ui.main.settings.Library
|
|||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||
|
|
@ -60,7 +64,11 @@ class NavDrawerService
|
|||
if (user != null && userDto != null && user.id == userDto.id) {
|
||||
updateNavDrawer(user, userDto)
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error updating nav drawer")
|
||||
showToast(context, "Error fetching user's views")
|
||||
}.launchIn(coroutineScope)
|
||||
|
||||
seerrServerRepository.active
|
||||
.onEach { discoverActive ->
|
||||
_state.update { it.copy(discoverEnabled = discoverActive) }
|
||||
|
|
@ -143,7 +151,9 @@ class NavDrawerService
|
|||
val allItems = builtins + libraries
|
||||
|
||||
val navDrawerPins =
|
||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||
withContext(Dispatchers.IO) {
|
||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||
}
|
||||
|
||||
val items = mutableListOf<NavDrawerItem>()
|
||||
val moreItems = mutableListOf<NavDrawerItem>()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.itemsApi
|
||||
import javax.inject.Inject
|
||||
|
|
@ -12,6 +14,7 @@ import javax.inject.Singleton
|
|||
class PeopleFavorites
|
||||
@Inject
|
||||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiClient,
|
||||
) {
|
||||
suspend fun getPeopleFor(item: BaseItem): List<Person> =
|
||||
|
|
@ -27,7 +30,14 @@ class PeopleFavorites
|
|||
val people =
|
||||
item.data.people
|
||||
?.letNotEmpty { people ->
|
||||
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
|
||||
people.map {
|
||||
Person.fromDto(
|
||||
context,
|
||||
it,
|
||||
favorites[it.id] ?: false,
|
||||
api,
|
||||
)
|
||||
}
|
||||
}.orEmpty()
|
||||
people
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,33 +158,58 @@ class RefreshRateService
|
|||
if (refreshRateSwitch) {
|
||||
displayModes
|
||||
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||
.filter {
|
||||
it.refreshRateRounded % streamRate == 0 || // Exact multiple
|
||||
it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
.filter { frameRateMatches(it.refreshRateRounded, streamRate) }
|
||||
} else {
|
||||
displayModes
|
||||
.filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth }
|
||||
}
|
||||
// Timber.v("display modes candidates: %s", candidates.joinToString("\n"))
|
||||
return if (!resolutionSwitch) {
|
||||
candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
candidates
|
||||
.filter { it.refreshRateRounded == streamRate }
|
||||
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
} else {
|
||||
// Exact resolution & frame rate
|
||||
candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight == streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
?: candidates.firstOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
// Next highest resolution, exact frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth >= streamWidth &&
|
||||
it.physicalHeight >= streamHeight &&
|
||||
it.refreshRateRounded == streamRate
|
||||
}
|
||||
// Exact resolution, acceptable frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth == streamWidth &&
|
||||
it.physicalHeight == streamHeight &&
|
||||
frameRateMatches(it.refreshRateRounded, streamRate)
|
||||
}
|
||||
// Next highest resolution, acceptable frame rate
|
||||
?: candidates.lastOrNull {
|
||||
it.physicalWidth >= streamWidth &&
|
||||
it.physicalHeight >= streamHeight &&
|
||||
frameRateMatches(it.refreshRateRounded, streamRate)
|
||||
}
|
||||
// Fall back to largest resolution, exact frame rate
|
||||
?: candidates
|
||||
.filter { it.refreshRateRounded == streamRate }
|
||||
.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
// Finally, just the highest resolution
|
||||
?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight }
|
||||
}
|
||||
}
|
||||
|
||||
fun frameRateMatches(
|
||||
refreshRateRounded: Int,
|
||||
streamRate: Int,
|
||||
): Boolean {
|
||||
return refreshRateRounded % streamRate == 0 || // Exact multiple
|
||||
refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
|
|||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
/**
|
||||
|
|
@ -24,6 +25,6 @@ class SeerrApi(
|
|||
baseUrl: String,
|
||||
apiKey: String?,
|
||||
) {
|
||||
api = SeerrApiClient(baseUrl, apiKey, okHttpClient)
|
||||
api = SeerrApiClient(createSeerrApiUrl(baseUrl), apiKey, okHttpClient)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings
|
|||
import com.github.damontecres.wholphin.api.seerr.model.User
|
||||
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAuthMethod
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.SeerrServer
|
||||
|
|
@ -18,15 +19,19 @@ import com.github.damontecres.wholphin.data.model.SeerrUser
|
|||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.setup.seerr.createSeerrApiUrl
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import dagger.hilt.android.qualifiers.ActivityContext
|
||||
import dagger.hilt.android.scopes.ActivityScoped
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.supervisorScope
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
|
@ -54,6 +59,7 @@ class SeerrServerRepository
|
|||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
|
||||
val currentUser: Flow<SeerrUser?> =
|
||||
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
|
||||
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
|
||||
|
||||
/**
|
||||
* Whether Seerr integration is currently active of not
|
||||
|
|
@ -67,10 +73,11 @@ class SeerrServerRepository
|
|||
}
|
||||
|
||||
fun error(
|
||||
serverUrl: String,
|
||||
server: SeerrServer,
|
||||
user: SeerrUser,
|
||||
exception: Exception,
|
||||
) {
|
||||
_connection.update { SeerrConnectionStatus.Error(serverUrl, exception) }
|
||||
_connection.update { SeerrConnectionStatus.Error(server, user, exception) }
|
||||
seerrApi.update("", null)
|
||||
}
|
||||
|
||||
|
|
@ -158,11 +165,11 @@ class SeerrServerRepository
|
|||
val apiKey = passwordOrApiKey.takeIf { authMethod == SeerrAuthMethod.API_KEY }
|
||||
val api =
|
||||
SeerrApiClient(
|
||||
url,
|
||||
createSeerrApiUrl(url),
|
||||
apiKey,
|
||||
okHttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(5.seconds)
|
||||
.connectTimeout(2.seconds)
|
||||
.readTimeout(6.seconds)
|
||||
.build(),
|
||||
)
|
||||
|
|
@ -170,10 +177,16 @@ class SeerrServerRepository
|
|||
return LoadingState.Success
|
||||
}
|
||||
|
||||
suspend fun removeServer() {
|
||||
val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return
|
||||
seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId)
|
||||
suspend fun removeServerForCurrentUser(): Boolean {
|
||||
val user =
|
||||
when (val conn = connection.first()) {
|
||||
SeerrConnectionStatus.NotConfigured -> return false
|
||||
is SeerrConnectionStatus.Error -> conn.user
|
||||
is SeerrConnectionStatus.Success -> conn.current.user
|
||||
}
|
||||
val rows = seerrServerDao.deleteUser(user)
|
||||
clear()
|
||||
return rows > 0
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -186,7 +199,8 @@ sealed interface SeerrConnectionStatus {
|
|||
data object NotConfigured : SeerrConnectionStatus
|
||||
|
||||
data class Error(
|
||||
val serverUrl: String,
|
||||
val server: SeerrServer,
|
||||
val user: SeerrUser,
|
||||
val ex: Exception,
|
||||
) : SeerrConnectionStatus
|
||||
|
||||
|
|
@ -266,47 +280,77 @@ class UserSwitchListener
|
|||
seerrServerRepository.clear()
|
||||
homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY }
|
||||
if (user != null) {
|
||||
// Check for home settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
}
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.lastOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server =
|
||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
try {
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} else {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
}
|
||||
seerrServerRepository.set(server, seerrUser, userConfig)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Error logging into %s",
|
||||
server.url,
|
||||
)
|
||||
seerrServerRepository.error(server.url, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
switchUser(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun switchUser(user: JellyfinUser) =
|
||||
supervisorScope {
|
||||
// Check for home settings
|
||||
launchIO {
|
||||
homeSettingsService.loadCurrentSettings(user.id)
|
||||
}
|
||||
// Check for seerr server
|
||||
launchIO {
|
||||
seerrServerDao
|
||||
.getUsersByJellyfinUser(user.rowId)
|
||||
.lastOrNull()
|
||||
?.let { seerrUser ->
|
||||
val server =
|
||||
seerrServerDao.getServer(seerrUser.serverId)?.server
|
||||
if (server != null) {
|
||||
Timber.i("Found a seerr user & server")
|
||||
try {
|
||||
seerrApi.update(server.url, seerrUser.credential)
|
||||
val userConfig =
|
||||
if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) {
|
||||
login(
|
||||
seerrApi.api,
|
||||
seerrUser.authMethod,
|
||||
seerrUser.username,
|
||||
seerrUser.password,
|
||||
)
|
||||
} else {
|
||||
seerrApi.api.usersApi.authMeGet()
|
||||
}
|
||||
seerrServerRepository.set(
|
||||
server,
|
||||
seerrUser,
|
||||
userConfig,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(
|
||||
ex,
|
||||
"Error logging into %s",
|
||||
server.url,
|
||||
)
|
||||
seerrServerRepository.error(server, seerrUser, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun CurrentSeerr?.imageUrlBuilder(
|
||||
imageType: ImageType,
|
||||
path: String?,
|
||||
): String? {
|
||||
if (this == null) return null
|
||||
val cacheImages = serverConfig.cacheImages == true
|
||||
val base =
|
||||
if (cacheImages) {
|
||||
server.url.removeSuffix("/") + "/imageproxy/tmdb"
|
||||
} else {
|
||||
"https://image.tmdb.org"
|
||||
}
|
||||
val prefix =
|
||||
when (imageType) {
|
||||
ImageType.PRIMARY -> "/t/p/w500"
|
||||
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
|
||||
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
||||
}
|
||||
return "${base}${prefix}$path"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,25 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCast
|
||||
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieResult
|
||||
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvResult
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.toLocalDate
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -22,6 +36,7 @@ class SeerrService
|
|||
constructor(
|
||||
private val seerApi: SeerrApi,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
) {
|
||||
val api: SeerrApiClient get() = seerApi.api
|
||||
|
||||
|
|
@ -40,35 +55,35 @@ class SeerrService
|
|||
api.searchApi
|
||||
.discoverTvGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
|
||||
suspend fun discoverMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
|
||||
suspend fun trending(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTrendingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingMovies(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverMoviesUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
|
||||
suspend fun upcomingTv(page: Int = 1): List<DiscoverItem> =
|
||||
api.searchApi
|
||||
.discoverTvUpcomingGet(page = page)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
|
||||
/**
|
||||
|
|
@ -89,14 +104,14 @@ class SeerrService
|
|||
api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> {
|
||||
api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = it)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
}
|
||||
|
||||
BaseItemKind.PERSON -> {
|
||||
|
|
@ -106,12 +121,12 @@ class SeerrService
|
|||
val cast =
|
||||
credits.cast
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
val crew =
|
||||
credits.crew
|
||||
?.take(25)
|
||||
?.map(::DiscoverItem)
|
||||
?.map { createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
cast + crew
|
||||
}
|
||||
|
|
@ -125,4 +140,186 @@ class SeerrService
|
|||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
suspend fun getTvSeries(item: BaseItem): TvDetails? =
|
||||
if (active.first()) {
|
||||
item.data.providerIds
|
||||
?.get("Tmdb")
|
||||
?.toIntOrNull()
|
||||
?.let { tvId ->
|
||||
api.tvApi.tvTvIdGet(tvId = tvId)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun createImageUrl(
|
||||
imageType: ImageType,
|
||||
path: String?,
|
||||
mediaInfo: MediaInfo?,
|
||||
): String? {
|
||||
if (mediaInfo != null) {
|
||||
val itemId =
|
||||
if (mediaInfo.jellyfinMediaId.isNotNullOrBlank()) {
|
||||
mediaInfo.jellyfinMediaId.toUUIDOrNull()
|
||||
} else if (mediaInfo.jellyfinMediaId4k.isNotNullOrBlank()) {
|
||||
mediaInfo.jellyfinMediaId4k.toUUIDOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (itemId != null) {
|
||||
return imageUrlService.getItemImageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
)
|
||||
}
|
||||
}
|
||||
val current = seerrServerRepository.current.firstOrNull() ?: return null
|
||||
val cacheImages = current.serverConfig.cacheImages == true
|
||||
val base =
|
||||
if (cacheImages) {
|
||||
current.server.url.removeSuffix("/") + "/imageproxy/tmdb"
|
||||
} else {
|
||||
"https://image.tmdb.org"
|
||||
}
|
||||
val prefix =
|
||||
when (imageType) {
|
||||
ImageType.PRIMARY -> "/t/p/w500"
|
||||
ImageType.BACKDROP -> "/t/p/w1920_and_h1080_multi_faces"
|
||||
else -> throw IllegalArgumentException("Image type not supported: $imageType")
|
||||
}
|
||||
return "${base}${prefix}$path"
|
||||
}
|
||||
|
||||
suspend fun createDiscoverItem(movie: MovieResult): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = movie.id,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(movie.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
||||
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(movie: MovieDetails): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = movie.id ?: -1,
|
||||
type = SeerrItemType.MOVIE,
|
||||
title = movie.title,
|
||||
subtitle = null,
|
||||
overview = movie.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(movie.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(movie.releaseDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, movie.posterPath, movie.mediaInfo),
|
||||
backDropUrl = createImageUrl(ImageType.BACKDROP, movie.backdropPath, movie.mediaInfo),
|
||||
jellyfinItemId = movie.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(tv: TvResult): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(tv.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
||||
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(tv: TvDetails): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = tv.id!!,
|
||||
type = SeerrItemType.TV,
|
||||
title = tv.name,
|
||||
subtitle = null,
|
||||
overview = tv.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(tv.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(tv.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, tv.posterPath, tv.mediaInfo),
|
||||
backDropUrl = createImageUrl(ImageType.BACKDROP, tv.backdropPath, tv.mediaInfo),
|
||||
jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(search: SeerrSearchResult): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = search.id,
|
||||
type = SeerrItemType.fromString(search.mediaType),
|
||||
title = search.title ?: search.name,
|
||||
subtitle = null,
|
||||
overview = search.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(search.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(search.releaseDate ?: search.firstAirDate),
|
||||
posterUrl = createImageUrl(ImageType.PRIMARY, search.posterPath, search.mediaInfo),
|
||||
backDropUrl = createImageUrl(ImageType.BACKDROP, search.backdropPath, search.mediaInfo),
|
||||
jellyfinItemId = search.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(credit: CreditCast): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.character,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterUrl =
|
||||
createImageUrl(
|
||||
ImageType.PRIMARY,
|
||||
credit.posterPath ?: credit.profilePath,
|
||||
credit.mediaInfo,
|
||||
),
|
||||
backDropUrl =
|
||||
createImageUrl(
|
||||
ImageType.BACKDROP,
|
||||
credit.backdropPath,
|
||||
credit.mediaInfo,
|
||||
),
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
|
||||
suspend fun createDiscoverItem(credit: CreditCrew): DiscoverItem =
|
||||
DiscoverItem(
|
||||
id = credit.id!!,
|
||||
type = SeerrItemType.fromString(credit.mediaType, SeerrItemType.PERSON),
|
||||
title = credit.name ?: credit.title,
|
||||
subtitle = credit.job,
|
||||
overview = credit.overview,
|
||||
availability =
|
||||
SeerrAvailability.from(credit.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
releaseDate = toLocalDate(credit.firstAirDate),
|
||||
posterUrl =
|
||||
createImageUrl(
|
||||
ImageType.PRIMARY,
|
||||
credit.posterPath ?: credit.profilePath,
|
||||
credit.mediaInfo,
|
||||
),
|
||||
backDropUrl =
|
||||
createImageUrl(
|
||||
ImageType.BACKDROP,
|
||||
credit.backdropPath,
|
||||
credit.mediaInfo,
|
||||
),
|
||||
jellyfinItemId = credit.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import javax.inject.Singleton
|
|||
class SetupNavigationManager
|
||||
@Inject
|
||||
constructor() {
|
||||
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
|
||||
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||
|
||||
/**
|
||||
* Go to the specified [SetupDestination]
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import androidx.work.Constraints
|
|||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
|
|
@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped
|
|||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
|
@ -31,7 +34,6 @@ class SuggestionsSchedulerService
|
|||
constructor(
|
||||
@param:ActivityContext private val context: Context,
|
||||
private val serverRepository: ServerRepository,
|
||||
private val cache: SuggestionsCache,
|
||||
private val workManager: WorkManager,
|
||||
) {
|
||||
private val activity =
|
||||
|
|
@ -42,6 +44,7 @@ class SuggestionsSchedulerService
|
|||
|
||||
// Exposed for testing
|
||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||
|
||||
init {
|
||||
serverRepository.current.observe(activity) { user ->
|
||||
|
|
@ -60,6 +63,28 @@ class SuggestionsSchedulerService
|
|||
userId: UUID,
|
||||
serverId: UUID,
|
||||
) {
|
||||
val workInfos =
|
||||
withContext(dispatcher) {
|
||||
workManager
|
||||
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||
.get()
|
||||
}
|
||||
val activeWork =
|
||||
workInfos.firstOrNull {
|
||||
!it.state.isFinished
|
||||
}
|
||||
val scheduledUserId =
|
||||
activeWork
|
||||
?.tags
|
||||
?.firstOrNull { it.startsWith("user:") }
|
||||
?.removePrefix("user:")
|
||||
|
||||
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
|
||||
if (isAlreadyScheduledForUser) {
|
||||
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
|
||||
return
|
||||
}
|
||||
|
||||
val constraints =
|
||||
Constraints
|
||||
.Builder()
|
||||
|
|
@ -75,12 +100,18 @@ class SuggestionsSchedulerService
|
|||
val periodicWorkRequestBuilder =
|
||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||
repeatInterval = 12.hours.toJavaDuration(),
|
||||
flexTimeInterval = 1.hours.toJavaDuration(),
|
||||
).setConstraints(constraints)
|
||||
.setBackoffCriteria(
|
||||
BackoffPolicy.EXPONENTIAL,
|
||||
15.minutes.toJavaDuration(),
|
||||
).setInputData(inputData)
|
||||
.setInitialDelay(60.seconds.toJavaDuration())
|
||||
.addTag("user:$userId")
|
||||
|
||||
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
|
||||
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
|
||||
|
||||
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
|
||||
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||
|
|
|
|||
|
|
@ -99,11 +99,17 @@ class SuggestionsWorker
|
|||
itemsPerRow,
|
||||
)
|
||||
ensureActive()
|
||||
val newIds = suggestions.map { it.id }
|
||||
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
|
||||
if (cachedIds == newIds) {
|
||||
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
|
||||
return@runCatching
|
||||
}
|
||||
cache.put(
|
||||
userId,
|
||||
view.id,
|
||||
itemKind,
|
||||
suggestions.map { it.id },
|
||||
newIds,
|
||||
)
|
||||
}.onFailure { e ->
|
||||
Timber.e(
|
||||
|
|
@ -242,7 +248,7 @@ class SuggestionsWorker
|
|||
GetItemsRequest(
|
||||
parentId = parentId,
|
||||
userId = userId,
|
||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
|
||||
fields = extraFields,
|
||||
includeItemTypes = listOf(itemKind),
|
||||
genreIds = genreIds,
|
||||
recursive = true,
|
||||
|
|
@ -252,7 +258,7 @@ class SuggestionsWorker
|
|||
sortOrder = sortOrder?.let { listOf(it) },
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
imageTypeLimit = 1,
|
||||
imageTypeLimit = 0,
|
||||
)
|
||||
return GetItemsRequestHandler
|
||||
.execute(api, request)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import androidx.core.content.ContextCompat
|
|||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.edit
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.github.damontecres.wholphin.BuildConfig
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.UpdateChecker.Companion.ASSET_NAME
|
||||
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
|
|
@ -51,9 +53,8 @@ class UpdateChecker
|
|||
@param:StandardOkHttpClient private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
companion object {
|
||||
// TODO apk names
|
||||
private const val ASSET_NAME = "Wholphin"
|
||||
private const val APK_NAME = "$ASSET_NAME.apk"
|
||||
const val ASSET_NAME = "Wholphin"
|
||||
const val APK_NAME = "$ASSET_NAME.apk"
|
||||
|
||||
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
|
||||
|
||||
|
|
@ -118,11 +119,6 @@ class UpdateChecker
|
|||
|
||||
suspend fun getLatestRelease(updateUrl: String): Release? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val preferRelease =
|
||||
PreferenceManager
|
||||
.getDefaultSharedPreferences(context)
|
||||
.getBoolean("updatePreferRelease", true)
|
||||
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
|
|
@ -140,7 +136,7 @@ class UpdateChecker
|
|||
val downloadUrl =
|
||||
result.jsonObject["assets"]
|
||||
?.jsonArray
|
||||
?.let { assets -> getDownloadUrl(assets, preferRelease) }
|
||||
?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) }
|
||||
Timber.v("version=$version, downloadUrl=$downloadUrl")
|
||||
if (version != null) {
|
||||
val notes =
|
||||
|
|
@ -165,35 +161,6 @@ class UpdateChecker
|
|||
}
|
||||
}
|
||||
|
||||
private fun getDownloadUrl(
|
||||
assets: JsonArray,
|
||||
preferRelease: Boolean,
|
||||
): String? {
|
||||
val abiSuffix = Build.SUPPORTED_ABIS.firstOrNull().let { if (it != null) "-$it" else "" }
|
||||
val releaseSuffix = if (preferRelease) "-release" else "-debug"
|
||||
val preferredNames =
|
||||
listOf(
|
||||
"$ASSET_NAME${releaseSuffix}$abiSuffix.apk",
|
||||
"$ASSET_NAME$releaseSuffix.apk",
|
||||
"$ASSET_NAME.apk",
|
||||
)
|
||||
var preferredAsset: JsonObject? = null
|
||||
outer@ for (name in preferredNames) {
|
||||
for (asset in assets) {
|
||||
val assetName =
|
||||
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
if (name == assetName) {
|
||||
preferredAsset = asset.jsonObject
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
return preferredAsset
|
||||
?.get("browser_download_url")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
}
|
||||
|
||||
suspend fun installRelease(
|
||||
release: Release,
|
||||
callback: DownloadCallback,
|
||||
|
|
@ -387,3 +354,33 @@ suspend fun copyTo(
|
|||
}
|
||||
return bytesCopied
|
||||
}
|
||||
|
||||
fun getDownloadUrl(
|
||||
assets: JsonArray,
|
||||
debug: Boolean,
|
||||
supportedAbis: List<String> = Build.SUPPORTED_ABIS.toList(),
|
||||
): String? {
|
||||
val abiSuffix = supportedAbis.firstOrNull().let { if (it != null) "-$it" else "" }
|
||||
val releaseSuffix = if (debug) "-debug" else "-release"
|
||||
val preferredNames =
|
||||
buildList {
|
||||
add("$ASSET_NAME${releaseSuffix}$abiSuffix.apk")
|
||||
add("$ASSET_NAME$releaseSuffix.apk")
|
||||
if (!debug) add("$ASSET_NAME.apk")
|
||||
}
|
||||
var preferredAsset: JsonObject? = null
|
||||
outer@ for (name in preferredNames) {
|
||||
for (asset in assets) {
|
||||
val assetName =
|
||||
asset.jsonObject["name"]?.jsonPrimitive?.contentOrNull
|
||||
if (name == assetName) {
|
||||
preferredAsset = asset.jsonObject
|
||||
break@outer
|
||||
}
|
||||
}
|
||||
}
|
||||
return preferredAsset
|
||||
?.get("browser_download_url")
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
|||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ class UserPreferencesService
|
|||
private val serverRepository: ServerRepository,
|
||||
private val preferencesDataStore: DataStore<AppPreferences>,
|
||||
) {
|
||||
val flow = preferencesDataStore.data.map { UserPreferences(it) }
|
||||
|
||||
suspend fun getCurrent(): UserPreferences =
|
||||
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
|
||||
val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance()
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
|
||||
import com.github.damontecres.wholphin.services.SeerrApi
|
||||
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||
import dagger.Module
|
||||
|
|
@ -17,6 +18,7 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
|
@ -48,6 +50,14 @@ annotation class IoCoroutineScope
|
|||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultCoroutineScope
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class IoDispatcher
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class DefaultDispatcher
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
|
@ -61,12 +71,6 @@ object AppModule {
|
|||
version = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo = androidDevice(context)
|
||||
|
||||
@StandardOkHttpClient
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -111,12 +115,12 @@ object AppModule {
|
|||
@Singleton
|
||||
fun okHttpFactory(
|
||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = OkHttpFactory(okHttpClient)
|
||||
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun jellyfin(
|
||||
okHttpFactory: OkHttpFactory,
|
||||
okHttpFactory: CoroutineContextApiClientFactory,
|
||||
@ApplicationContext context: Context,
|
||||
clientInfo: ClientInfo,
|
||||
deviceInfo: DeviceInfo,
|
||||
|
|
@ -167,7 +171,7 @@ object AppModule {
|
|||
if (preferences.appPreferences.interfacePreferences.rememberSelectedTab) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
appPreference.updateData {
|
||||
preferences.appPreferences.updateInterfacePreferences {
|
||||
it.updateInterfacePreferences {
|
||||
putRememberedTabs(key(itemId), tabIndex)
|
||||
}
|
||||
}
|
||||
|
|
@ -176,15 +180,29 @@ object AppModule {
|
|||
}
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoDispatcher
|
||||
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@IoCoroutineScope
|
||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
fun ioCoroutineScope(
|
||||
@IoDispatcher dispatcher: CoroutineDispatcher,
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@DefaultDispatcher
|
||||
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@DefaultCoroutineScope
|
||||
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
fun defaultCoroutineScope(
|
||||
@DefaultDispatcher dispatcher: CoroutineDispatcher,
|
||||
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
@ -198,3 +216,13 @@ object AppModule {
|
|||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||
) = SeerrApi(okHttpClient)
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DeviceModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun deviceInfo(
|
||||
@ApplicationContext context: Context,
|
||||
): DeviceInfo = androidDevice(context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import android.media.AudioManager
|
||||
import android.view.KeyEvent
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.MarqueeAnimationMode
|
||||
import androidx.compose.foundation.basicMarquee
|
||||
|
|
@ -46,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient
|
|||
import org.jellyfin.sdk.api.client.Response
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.extensions.ticks
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
|
|
@ -296,7 +294,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) =
|
|||
}
|
||||
|
||||
/**
|
||||
* Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn].
|
||||
* Tries to find the [Activity] for the given [Context]
|
||||
*/
|
||||
fun Context.findActivity(): Activity? {
|
||||
if (this is Activity) {
|
||||
|
|
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
|
|||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the screen on for an [Activity]. Often used with [findActivity].
|
||||
*/
|
||||
fun Activity.keepScreenOn(keep: Boolean) {
|
||||
Timber.v("Keep screen on: $keep")
|
||||
if (keep) {
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
} else {
|
||||
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectively log errors from Coil image loading.
|
||||
*
|
||||
|
|
@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
|
|||
useSeriesForPrimary: Boolean,
|
||||
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) }
|
||||
|
||||
@Composable
|
||||
fun rememberBackDropImage(item: BaseItem): String? {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this, coalescing nulls to zero, is greater than that
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent
|
|||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
import timber.log.Timber
|
||||
|
|
@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String?
|
|||
append(productionYear.toString())
|
||||
if (status == "Continuing") {
|
||||
append(" - ")
|
||||
append("Present")
|
||||
append(WholphinApplication.instance.getString(R.string.series_continueing))
|
||||
} else if (status == "Ended") {
|
||||
endDate?.let {
|
||||
if (it.year != productionYear) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ val DefaultItemFields =
|
|||
ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
@ -72,6 +73,7 @@ val SlimItemFields =
|
|||
ItemFields.SORT_NAME,
|
||||
ItemFields.MEDIA_SOURCE_COUNT,
|
||||
ItemFields.PARENT_ID,
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
|
||||
val PhotoItemFields =
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ fun BannerCard(
|
|||
interactionSource: MutableInteractionSource? = null,
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = true,
|
||||
) {
|
||||
val imageUrlService = LocalImageUrlService.current
|
||||
val density = LocalDensity.current
|
||||
|
|
@ -82,7 +83,7 @@ fun BannerCard(
|
|||
}
|
||||
}
|
||||
val imageUrl =
|
||||
remember(item, fillHeight, imageType) {
|
||||
remember(item, fillHeight, imageType, useSeriesForPrimary) {
|
||||
if (item != null) {
|
||||
item.imageUrlOverride
|
||||
?: imageUrlService.getItemImageUrl(
|
||||
|
|
@ -90,6 +91,7 @@ fun BannerCard(
|
|||
imageType,
|
||||
fillWidth = null,
|
||||
fillHeight = fillHeight,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
|
@ -208,6 +210,7 @@ fun BannerCardWithTitle(
|
|||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
imageType: ImageType = ImageType.PRIMARY,
|
||||
imageContentScale: ContentScale = ContentScale.FillBounds,
|
||||
useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true,
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
|
||||
|
|
@ -234,6 +237,7 @@ fun BannerCardWithTitle(
|
|||
interactionSource = interactionSource,
|
||||
imageType = imageType,
|
||||
imageContentScale = imageContentScale,
|
||||
useSeriesForPrimary = useSeriesForPrimary,
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
|
|
@ -57,7 +58,7 @@ fun <T> ItemRow(
|
|||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
LazyRow(
|
||||
state = state,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -33,6 +33,7 @@ import androidx.compose.ui.focus.FocusRequester
|
|||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -41,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
|
|
@ -55,11 +57,16 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
|||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride
|
||||
import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
|
|
@ -72,15 +79,19 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.playback.scale
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.ui.toServerString
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.ui.util.FilterUtils
|
||||
import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec
|
||||
import com.github.damontecres.wholphin.util.ApiRequestPager
|
||||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -93,6 +104,9 @@ import dagger.assisted.AssistedInject
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
|
|
@ -120,7 +134,10 @@ class CollectionFolderViewModel
|
|||
private val libraryDisplayInfoDao: LibraryDisplayInfoDao,
|
||||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val backdropService: BackdropService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val navigationManager: NavigationManager,
|
||||
private val themeSongPlayer: ThemeSongPlayer,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
val mediaReportService: MediaReportService,
|
||||
@Assisted itemId: String,
|
||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||
|
|
@ -157,9 +174,10 @@ class CollectionFolderViewModel
|
|||
viewModelScope.launchIO {
|
||||
super.itemId = itemId
|
||||
try {
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
val item =
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
fetchItem(it)
|
||||
}
|
||||
|
||||
val libraryDisplayInfo =
|
||||
serverRepository.currentUser.value?.let { user ->
|
||||
|
|
@ -184,11 +202,42 @@ class CollectionFolderViewModel
|
|||
}
|
||||
|
||||
loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary)
|
||||
.join()
|
||||
// onResumePage()
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error during init")
|
||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||
}
|
||||
}
|
||||
mediaManagementService.deletedItemFlow
|
||||
.onEach { deletedItem ->
|
||||
refreshAfterDelete(position, deletedItem.item)
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error refreshing after deleted item")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private suspend fun refreshAfterDelete(
|
||||
position: Int,
|
||||
deletedItem: BaseItem,
|
||||
) {
|
||||
try {
|
||||
val pager =
|
||||
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||
position.let {
|
||||
Timber.v("Item deleted: position=%s, id=%s", it, itemId)
|
||||
val item = pager?.get(it)
|
||||
// Exact item deleted (eg a movie) or deleted item was within the series
|
||||
if (item?.id == deletedItem.id ||
|
||||
equalsNotNull(item?.data?.id, deletedItem.data.seriesId)
|
||||
) {
|
||||
pager?.refreshPagesAfter(position)
|
||||
}
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error refreshing after deleted item %s", itemId)
|
||||
showToast(context, "Error refreshing after item deleted")
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLibraryDisplayInfo(
|
||||
|
|
@ -254,34 +303,32 @@ class CollectionFolderViewModel
|
|||
recursive: Boolean,
|
||||
filter: GetItemsFilter,
|
||||
useSeriesForPrimary: Boolean,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
) = viewModelScope.launch(Dispatchers.IO) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (resetState) {
|
||||
loading.value = DataLoadingState.Loading
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
backgroundLoading.value = LoadingState.Loading
|
||||
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
|
||||
this@CollectionFolderViewModel.filter.value = filter
|
||||
}
|
||||
try {
|
||||
val newPager =
|
||||
createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init()
|
||||
if (newPager.isNotEmpty()) newPager.getBlocking(0)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Success(newPager)
|
||||
backgroundLoading.value = LoadingState.Success
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(
|
||||
ex,
|
||||
"Exception while loading data: sort=%s, filter=%s",
|
||||
sortAndDirection,
|
||||
filter,
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
loading.value = DataLoadingState.Error(ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -443,6 +490,46 @@ class CollectionFolderViewModel
|
|||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateTo(destination: Destination) {
|
||||
release()
|
||||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
fun release() {
|
||||
themeSongPlayer.stop()
|
||||
}
|
||||
|
||||
fun onResumePage() {
|
||||
viewModelScope.launchIO {
|
||||
item.value?.let {
|
||||
Timber.v("onResumePage: %s", loading.value!!::class)
|
||||
if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) {
|
||||
val volume =
|
||||
userPreferencesService
|
||||
.getCurrent()
|
||||
.appPreferences.interfacePreferences.playThemeSongs
|
||||
themeSongPlayer.playThemeFor(it.id, volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
index: Int,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
refreshAfterDelete(index, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -530,6 +617,7 @@ fun CollectionFolderGrid(
|
|||
|
||||
var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
when (val state = loading) {
|
||||
|
|
@ -548,6 +636,13 @@ fun CollectionFolderGrid(
|
|||
?: item?.data?.collectionType?.name
|
||||
?: stringResource(R.string.collection)
|
||||
Box(modifier = modifier) {
|
||||
LifecycleResumeEffect(itemId) {
|
||||
viewModel.onResumePage()
|
||||
|
||||
onPauseOrDispose {
|
||||
viewModel.release()
|
||||
}
|
||||
}
|
||||
CollectionFolderGridContent(
|
||||
preferences = preferences,
|
||||
initialPosition = viewModel.position,
|
||||
|
|
@ -598,7 +693,7 @@ fun CollectionFolderGrid(
|
|||
} else {
|
||||
Destination.Playback(item)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
onClickPlayAll = { shuffle ->
|
||||
itemId.toUUIDOrNull()?.let {
|
||||
|
|
@ -622,7 +717,7 @@ fun CollectionFolderGrid(
|
|||
filter = filter,
|
||||
)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -658,9 +753,10 @@ fun CollectionFolderGrid(
|
|||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
navigateTo = { viewModel.navigateTo(it) },
|
||||
onClickWatch = { itemId, watched ->
|
||||
viewModel.setWatched(position, itemId, watched)
|
||||
},
|
||||
|
|
@ -672,6 +768,12 @@ fun CollectionFolderGrid(
|
|||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = PositionItem(position, item)
|
||||
},
|
||||
onClickGoTo = {
|
||||
onClickItem.invoke(position, it)
|
||||
},
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
@ -696,8 +798,19 @@ fun CollectionFolderGrid(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun CollectionFolderGridContent(
|
||||
preferences: UserPreferences,
|
||||
|
|
@ -760,8 +873,8 @@ fun CollectionFolderGridContent(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader || loadingState !is DataLoadingState.Success,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
|
@ -842,14 +955,16 @@ fun CollectionFolderGridContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current
|
||||
val density = LocalDensity.current
|
||||
AnimatedVisibility(viewOptions.showDetails) {
|
||||
HomePageHeader(
|
||||
item = focusedItem,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(140.dp)
|
||||
.padding(16.dp),
|
||||
.height(200.dp)
|
||||
.padding(top = 48.dp, bottom = 32.dp, start = 8.dp),
|
||||
)
|
||||
}
|
||||
when (val state = loadingState) {
|
||||
|
|
@ -857,7 +972,7 @@ fun CollectionFolderGridContent(
|
|||
DataLoadingState.Loading,
|
||||
-> {
|
||||
// This shouldn't happen, so just show placeholder
|
||||
Text("Loading")
|
||||
Text(stringResource(R.string.loading))
|
||||
}
|
||||
|
||||
is DataLoadingState.Error -> {
|
||||
|
|
@ -895,6 +1010,15 @@ fun CollectionFolderGridContent(
|
|||
},
|
||||
columns = viewOptions.columns,
|
||||
spacing = viewOptions.spacing.dp,
|
||||
bringIntoViewSpec =
|
||||
remember(viewOptions) {
|
||||
val spacingPx = with(density) { viewOptions.spacing.dp.toPx() }
|
||||
if (viewOptions.showDetails) {
|
||||
ScrollToTopBringIntoViewSpec(spacingPx)
|
||||
} else {
|
||||
defaultBringIntoViewSpec
|
||||
}
|
||||
},
|
||||
)
|
||||
AnimatedVisibility(showViewOptions) {
|
||||
ViewOptionsDialog(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
|
|
@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
|
@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||
import com.github.damontecres.wholphin.ui.ifElse
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||
import com.github.damontecres.wholphin.ui.playback.isDown
|
||||
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -259,31 +271,65 @@ fun DialogPopupContent(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier,
|
||||
) {
|
||||
items(dialogItems) {
|
||||
when (it) {
|
||||
itemsIndexed(dialogItems) { index, item ->
|
||||
when (item) {
|
||||
is DialogItemDivider -> {
|
||||
HorizontalDivider(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
is DialogItem -> {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
ListItem(
|
||||
selected = it.selected,
|
||||
enabled = !waiting && it.enabled,
|
||||
selected = item.selected,
|
||||
enabled = !waiting && item.enabled,
|
||||
onClick = {
|
||||
if (dismissOnClick) {
|
||||
onDismissRequest.invoke()
|
||||
}
|
||||
it.onClick.invoke()
|
||||
item.onClick.invoke()
|
||||
},
|
||||
headlineContent = it.headlineContent,
|
||||
overlineContent = it.overlineContent,
|
||||
supportingContent = it.supportingContent,
|
||||
leadingContent = it.leadingContent,
|
||||
trailingContent = it.trailingContent,
|
||||
modifier = Modifier,
|
||||
headlineContent = item.headlineContent,
|
||||
overlineContent = item.overlineContent,
|
||||
supportingContent = item.supportingContent,
|
||||
leadingContent = item.leadingContent,
|
||||
trailingContent = item.trailingContent,
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier
|
||||
.focusRequester(focusRequesters[index])
|
||||
.ifElse(
|
||||
index == 0,
|
||||
Modifier.onKeyEvent {
|
||||
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(dialogItems.lastIndex)
|
||||
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
},
|
||||
).ifElse(
|
||||
index == dialogItems.lastIndex,
|
||||
Modifier.onKeyEvent {
|
||||
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
focusRequesters[0].tryRequestFocus()
|
||||
}
|
||||
return@onKeyEvent true
|
||||
}
|
||||
false
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -471,6 +517,80 @@ fun ConfirmDialogContent(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConfirmDeleteDialog(
|
||||
itemTitle: String,
|
||||
onCancel: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
) {
|
||||
BasicDialog(
|
||||
onDismissRequest = onCancel,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.delete_item),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = itemTitle,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onCancel,
|
||||
modifier = Modifier.width(72.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.cancel),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = onConfirm,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = Color.Red.copy(alpha = .8f),
|
||||
modifier = Modifier,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.confirm),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun chooseVersionParams(
|
||||
context: Context,
|
||||
sources: List<MediaSourceInfo>,
|
||||
|
|
@ -512,6 +632,7 @@ fun chooseStream(
|
|||
streams: List<MediaStream>,
|
||||
currentIndex: Int?,
|
||||
type: MediaStreamType,
|
||||
preferredSubtitleLanguage: String?,
|
||||
onClick: (Int) -> Unit,
|
||||
): DialogParams =
|
||||
DialogParams(
|
||||
|
|
@ -536,6 +657,9 @@ fun chooseStream(
|
|||
)
|
||||
add(
|
||||
DialogItem(
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||
},
|
||||
|
|
@ -546,22 +670,32 @@ fun chooseStream(
|
|||
)
|
||||
}
|
||||
addAll(
|
||||
streams.filter { it.type == type }.mapIndexed { index, stream ->
|
||||
val simpleStream = SimpleMediaStream.from(context, stream, true)
|
||||
DialogItem(
|
||||
selected = currentIndex == stream.index,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == stream.index)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(text = simpleStream.streamTitle ?: simpleStream.displayTitle)
|
||||
},
|
||||
supportingContent = {
|
||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||
},
|
||||
onClick = { onClick.invoke(stream.index) },
|
||||
)
|
||||
},
|
||||
streams
|
||||
.filter { it.type == type }
|
||||
.let {
|
||||
if (type == MediaStreamType.SUBTITLE && preferredSubtitleLanguage.isNotNullOrBlank()) {
|
||||
it.sortedByDescending { it.language != null && it.language == preferredSubtitleLanguage }
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.mapIndexed { index, stream ->
|
||||
val simpleStream = SimpleMediaStream.from(context, stream, true)
|
||||
DialogItem(
|
||||
selected = currentIndex == stream.index,
|
||||
leadingContent = {
|
||||
SelectedLeadingContent(currentIndex == stream.index)
|
||||
},
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = simpleStream.streamTitle ?: simpleStream.displayTitle,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
if (simpleStream.streamTitle != null) Text(text = simpleStream.displayTitle)
|
||||
},
|
||||
onClick = { onClick.invoke(stream.index) },
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
|
||||
import com.github.damontecres.wholphin.data.model.GetItemsFilter
|
||||
import com.github.damontecres.wholphin.data.model.createGenreDestination
|
||||
import com.github.damontecres.wholphin.services.ImageUrlService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
|
|
@ -30,13 +29,13 @@ import com.github.damontecres.wholphin.ui.SlimItemFields
|
|||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGrid
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
|
||||
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import com.mayakapps.kache.InMemoryKache
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
|
|
@ -57,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields
|
|||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.request.GetGenresRequest
|
||||
import org.jellyfin.sdk.model.api.request.GetItemsRequest
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
|
||||
@HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
|
||||
class GenreViewModel
|
||||
|
|
@ -112,6 +113,7 @@ class GenreViewModel
|
|||
val genreToUrl =
|
||||
getGenreImageMap(
|
||||
api = api,
|
||||
userId = serverRepository.currentUser.value?.id,
|
||||
scope = viewModelScope,
|
||||
imageUrlService = imageUrlService,
|
||||
genres = genres.map { it.id },
|
||||
|
|
@ -143,15 +145,35 @@ class GenreViewModel
|
|||
}
|
||||
}
|
||||
|
||||
data class GenreCacheKey(
|
||||
val userId: UUID?,
|
||||
val parentId: UUID,
|
||||
)
|
||||
|
||||
private val genreCache by lazy {
|
||||
InMemoryKache<GenreCacheKey, Map<UUID, String?>>(8) {
|
||||
expireAfterWriteDuration = 2.hours
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getGenreImageMap(
|
||||
api: ApiClient,
|
||||
userId: UUID?,
|
||||
scope: CoroutineScope,
|
||||
imageUrlService: ImageUrlService,
|
||||
genres: List<UUID>,
|
||||
parentId: UUID,
|
||||
includeItemTypes: List<BaseItemKind>?,
|
||||
cardWidthPx: Int?,
|
||||
useCache: Boolean = true,
|
||||
): Map<UUID, String?> {
|
||||
val key = GenreCacheKey(userId, parentId)
|
||||
if (useCache) {
|
||||
genreCache.getIfAvailable(key)?.let {
|
||||
Timber.v("Got cached entry")
|
||||
return it
|
||||
}
|
||||
}
|
||||
val genreToUrl = ConcurrentHashMap<UUID, String?>()
|
||||
val semaphore = Semaphore(4)
|
||||
genres
|
||||
|
|
@ -163,6 +185,7 @@ suspend fun getGenreImageMap(
|
|||
.execute(
|
||||
api,
|
||||
GetItemsRequest(
|
||||
userId = userId,
|
||||
parentId = parentId,
|
||||
recursive = true,
|
||||
limit = 1,
|
||||
|
|
@ -186,11 +209,13 @@ suspend fun getGenreImageMap(
|
|||
imageType = ImageType.BACKDROP,
|
||||
imageTags = item.imageTags.orEmpty(),
|
||||
fillWidth = cardWidthPx,
|
||||
backdropTags = item.backdropImageTags.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
genreCache.put(key, genreToUrl)
|
||||
return genreToUrl
|
||||
}
|
||||
|
||||
|
|
@ -256,23 +281,12 @@ fun GenreCardGrid(
|
|||
pager = genres,
|
||||
onClickItem = { _, genre ->
|
||||
viewModel.navigationManager.navigateTo(
|
||||
Destination.FilteredCollection(
|
||||
itemId = itemId,
|
||||
filter =
|
||||
CollectionFolderFilter(
|
||||
nameOverride =
|
||||
listOfNotNull(
|
||||
genre.name,
|
||||
item?.title,
|
||||
).joinToString(" "),
|
||||
filter =
|
||||
GetItemsFilter(
|
||||
genres = listOf(genre.id),
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
useSavedLibraryDisplayInfo = false,
|
||||
),
|
||||
recursive = true,
|
||||
createGenreDestination(
|
||||
genreId = genre.id,
|
||||
genreName = genre.name,
|
||||
parentId = itemId,
|
||||
parentName = item?.title,
|
||||
includeItemTypes = includeItemTypes,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
|
|||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
|
|
@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer
|
|||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
|
|
@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.LocalContentColor
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
|
|
@ -65,12 +72,14 @@ fun ExpandablePlayButtons(
|
|||
resumePosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
trailers: List<Trailer>?,
|
||||
playOnClick: (position: Duration) -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
trailerOnClick: (Trailer) -> Unit,
|
||||
deleteOnClick: () -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -151,6 +160,16 @@ fun ExpandablePlayButtons(
|
|||
)
|
||||
}
|
||||
}
|
||||
if (canDelete) {
|
||||
item("delete") {
|
||||
DeleteButton(
|
||||
onClick = deleteOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// More button
|
||||
item("more") {
|
||||
|
|
@ -234,7 +253,7 @@ fun ExpandablePlayButton(
|
|||
fun ExpandablePlayButton(
|
||||
@StringRes title: Int,
|
||||
resume: Duration,
|
||||
icon: @Composable () -> Unit,
|
||||
icon: @Composable BoxScope.() -> Unit,
|
||||
onClick: (position: Duration) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
|
|
@ -254,12 +273,13 @@ fun ExpandablePlayButton(
|
|||
interactionSource = interactionSource,
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 2.dp, top = 2.dp)
|
||||
.padding(start = 2.dp)
|
||||
.height(MinButtonSize),
|
||||
) {
|
||||
icon.invoke()
|
||||
icon.invoke(this)
|
||||
}
|
||||
AnimatedVisibility(isFocused) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
|
@ -359,6 +379,48 @@ fun TrailerButton(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
val focused by interactionSource.collectIsFocusedAsState()
|
||||
val iconTint by
|
||||
animateColorAsState(
|
||||
targetValue =
|
||||
if (focused) {
|
||||
Color.Red.copy(alpha = .8f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(
|
||||
alpha = 0.8f,
|
||||
)
|
||||
},
|
||||
animationSpec =
|
||||
tween(
|
||||
easing = LinearEasing,
|
||||
),
|
||||
)
|
||||
ExpandablePlayButton(
|
||||
title = R.string.delete,
|
||||
resume = Duration.ZERO,
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = null,
|
||||
tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(start = 2.dp)
|
||||
.size(24.dp),
|
||||
)
|
||||
},
|
||||
onClick = { onClick.invoke() },
|
||||
interactionSource = interactionSource,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewTvSpec
|
||||
@Composable
|
||||
private fun ExpandablePlayButtonsPreview() {
|
||||
|
|
@ -374,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() {
|
|||
buttonOnFocusChanged = {},
|
||||
trailers = listOf(),
|
||||
trailerOnClick = {},
|
||||
canDelete = true,
|
||||
deleteOnClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -417,12 +481,19 @@ private fun ViewOptionsPreview() {
|
|||
onClick = {},
|
||||
modifier = Modifier,
|
||||
)
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
)
|
||||
SortByButton(
|
||||
sortOptions = listOf(),
|
||||
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
||||
onSortChange = {},
|
||||
)
|
||||
}
|
||||
DeleteButton(
|
||||
onClick = {},
|
||||
interactionSource = source,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
|
|
@ -31,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageContent
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
|
|
@ -51,6 +55,7 @@ abstract class RecommendedViewModel(
|
|||
val favoriteWatchManager: FavoriteWatchManager,
|
||||
val mediaReportService: MediaReportService,
|
||||
private val backdropService: BackdropService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
) : ViewModel() {
|
||||
abstract fun init()
|
||||
|
||||
|
|
@ -117,6 +122,25 @@ abstract class RecommendedViewModel(
|
|||
}
|
||||
update(title, row)
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
position: RowColumn,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
val row = rows.value.getOrNull(position.row)
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
(row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -130,6 +154,7 @@ fun RecommendedContent(
|
|||
val context = LocalContext.current
|
||||
var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
OneTimeLaunchedEffect {
|
||||
|
|
@ -196,6 +221,7 @@ fun RecommendedContent(
|
|||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = { viewModel.navigationManager.navigateTo(it) },
|
||||
|
|
@ -210,6 +236,7 @@ fun RecommendedContent(
|
|||
showPlaylistDialog.makePresent(it)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = RowColumnItem(position, item) },
|
||||
),
|
||||
),
|
||||
onDismissRequest = { moreDialog.makeAbsent() },
|
||||
|
|
@ -234,9 +261,19 @@ fun RecommendedContent(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RowColumnItem(
|
||||
data class RowColumnItem(
|
||||
val position: RowColumn,
|
||||
val item: BaseItem,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
|
|||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
|
|
@ -63,12 +64,14 @@ class RecommendedMovieViewModel
|
|||
favoriteWatchManager: FavoriteWatchManager,
|
||||
mediaReportService: MediaReportService,
|
||||
backdropService: BackdropService,
|
||||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.LatestNextUpService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SuggestionService
|
||||
|
|
@ -67,12 +68,14 @@ class RecommendedTvShowViewModel
|
|||
favoriteWatchManager: FavoriteWatchManager,
|
||||
mediaReportService: MediaReportService,
|
||||
backdropService: BackdropService,
|
||||
mediaManagementService: MediaManagementService,
|
||||
) : RecommendedViewModel(
|
||||
context,
|
||||
navigationManager,
|
||||
favoriteWatchManager,
|
||||
mediaReportService,
|
||||
backdropService,
|
||||
mediaManagementService,
|
||||
) {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import android.widget.Toast
|
|||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler
|
|||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -43,7 +45,13 @@ class AddPlaylistViewModel
|
|||
itemId: UUID,
|
||||
) {
|
||||
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
|
||||
playlistCreator.addToServerPlaylist(playlistId, itemId)
|
||||
try {
|
||||
playlistCreator.addToServerPlaylist(playlistId, itemId)
|
||||
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error adding %s to playlist %s", itemId, playlistId)
|
||||
showToast(context, "Error: ${ex.localizedMessage}", Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +63,8 @@ class AddPlaylistViewModel
|
|||
val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId))
|
||||
if (playlistId == null) {
|
||||
showToast(context, "Error creating playlist", Toast.LENGTH_LONG)
|
||||
} else {
|
||||
showToast(context, context.getString(R.string.success), Toast.LENGTH_SHORT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import androidx.annotation.StringRes
|
|||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow
|
|||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -112,6 +115,7 @@ fun <T : CardGridItem> CardGrid(
|
|||
},
|
||||
columns: Int = 6,
|
||||
spacing: Dp = 16.dp,
|
||||
bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current,
|
||||
) {
|
||||
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
|
||||
|
||||
|
|
@ -269,100 +273,102 @@ fun <T : CardGridItem> CardGrid(
|
|||
Box(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(columns),
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(vertical = 16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusRestorer(firstFocus)
|
||||
.focusProperties {
|
||||
onExit = {
|
||||
// Leaving the grid, so "forget" the position
|
||||
// focusedIndex = -1
|
||||
}
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
onEnter = {
|
||||
if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) {
|
||||
focusedIndex = startPosition
|
||||
}
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
},
|
||||
) {
|
||||
items(pager.size) { index ->
|
||||
val mod =
|
||||
if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) {
|
||||
if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index")
|
||||
Modifier
|
||||
.focusRequester(firstFocus)
|
||||
.focusRequester(gridFocusRequester)
|
||||
.focusRequester(alphabetFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
val item = pager[index]
|
||||
cardContent(
|
||||
item,
|
||||
{
|
||||
if (item != null) {
|
||||
focusedIndex = index
|
||||
onClickItem.invoke(index, item)
|
||||
}
|
||||
},
|
||||
{ if (item != null) onLongClickItem.invoke(index, item) },
|
||||
mod
|
||||
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
|
||||
.onFocusChanged { focusState ->
|
||||
if (DEBUG) {
|
||||
Timber.v(
|
||||
"$index isFocused=${focusState.isFocused}",
|
||||
)
|
||||
}
|
||||
if (focusState.isFocused) {
|
||||
// Focused, so set that up
|
||||
focusOn(index)
|
||||
positionCallback?.invoke(columns, index)
|
||||
} else if (focusedIndex == index) {
|
||||
// savedFocusedIndex = index
|
||||
// // Was focused on this, so mark unfocused
|
||||
// focusedIndex = -1
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (pager.isEmpty()) {
|
||||
if (pager.isEmpty()) {
|
||||
// focusedIndex = -1
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_results),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
if (showFooter) {
|
||||
// Footer
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.background(AppColors.TransparentBlack50),
|
||||
) {
|
||||
val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?"
|
||||
// if (focusedIndex >= 0) {
|
||||
// focusedIndex + 1
|
||||
// } else {
|
||||
// max(savedFocusedIndex, focusedIndexOnExit) + 1
|
||||
// }
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
text = "$index / ${pager.size}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -127,8 +125,8 @@ fun CollectionFolderLiveTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -76,8 +74,8 @@ fun CollectionFolderMovie(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -59,13 +59,13 @@ fun CollectionFolderPhotoAlbum(
|
|||
index = index,
|
||||
filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()),
|
||||
sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT,
|
||||
recursive = true,
|
||||
recursive = recursive,
|
||||
startSlideshow = false,
|
||||
)
|
||||
} else {
|
||||
item.destination(index)
|
||||
}
|
||||
viewModel.navigationManager.navigateTo(destination)
|
||||
viewModel.navigateTo(destination)
|
||||
},
|
||||
itemId = itemId.toServerString(),
|
||||
initialFilter = filter,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -80,8 +78,8 @@ fun CollectionFolderTv(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ data class MoreDialogActions(
|
|||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||
val onClickAddPlaylist: (UUID) -> Unit,
|
||||
val onSendMediaInfo: (UUID) -> Unit,
|
||||
val onClickDelete: (BaseItem) -> Unit,
|
||||
val onClickGoTo: (BaseItem) -> Unit = { navigateTo(it.destination()) },
|
||||
)
|
||||
|
||||
enum class ClearChosenStreams {
|
||||
|
|
@ -62,6 +64,7 @@ fun buildMoreDialogItems(
|
|||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canClearChosenStreams: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
onChooseVersion: () -> Unit,
|
||||
onChooseTracks: (MediaStreamType) -> Unit,
|
||||
|
|
@ -139,6 +142,17 @@ fun buildMoreDialogItems(
|
|||
actions.onClickAddPlaylist.invoke(item.id)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
|
|
@ -223,6 +237,7 @@ fun buildMoreDialogItemsForHome(
|
|||
playbackPosition: Duration,
|
||||
watched: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
actions: MoreDialogActions,
|
||||
): List<DialogItem> =
|
||||
buildList {
|
||||
|
|
@ -232,7 +247,7 @@ fun buildMoreDialogItemsForHome(
|
|||
context.getString(R.string.go_to),
|
||||
Icons.Default.ArrowForward,
|
||||
) {
|
||||
actions.navigateTo(item.destination())
|
||||
actions.onClickGoTo(item)
|
||||
},
|
||||
)
|
||||
if (item.type in supportedPlayableTypes) {
|
||||
|
|
@ -290,6 +305,17 @@ fun buildMoreDialogItemsForHome(
|
|||
actions.onClickAddPlaylist.invoke(itemId)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
actions.onClickDelete.invoke(item)
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
DialogItem(
|
||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.detail
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -98,8 +96,8 @@ fun FavoritesPage(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
|||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
|
|
@ -102,15 +101,6 @@ fun DiscoverMovieDetails(
|
|||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
onClickWatch = { itemId, watched -> },
|
||||
onClickFavorite = { itemId, favorite -> },
|
||||
onClickAddPlaylist = { itemId -> },
|
||||
onSendMediaInfo = {},
|
||||
)
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class DiscoverMovieViewModel
|
|||
) {
|
||||
Timber.v("Init for movie %s", item.id)
|
||||
val movie = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(movie)
|
||||
val discoveredItem = seerrService.createDiscoverItem(movie)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateCanCancel()
|
||||
|
|
@ -121,7 +121,7 @@ class DiscoverMovieViewModel
|
|||
seerrService.api.moviesApi
|
||||
.movieMovieIdSimilarGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
|
|
@ -130,7 +130,7 @@ class DiscoverMovieViewModel
|
|||
seerrService.api.moviesApi
|
||||
.movieMovieIdRecommendationsGet(movieId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
recommended.setValueOnMain(result)
|
||||
}
|
||||
|
|
@ -138,11 +138,11 @@ class DiscoverMovieViewModel
|
|||
val people =
|
||||
movie.credits
|
||||
?.cast
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty() +
|
||||
movie.credits
|
||||
?.crew
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
this@DiscoverMovieViewModel.people.setValueOnMain(people)
|
||||
val trailers =
|
||||
|
|
|
|||
|
|
@ -67,11 +67,11 @@ class DiscoverPersonViewModel
|
|||
.let { credits ->
|
||||
val cast =
|
||||
credits.cast
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
val crew =
|
||||
credits.crew
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
cast + crew
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
|
|
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
|
|||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showRequestSeasonDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val requestStr = stringResource(R.string.request)
|
||||
val request4kStr = stringResource(R.string.request_4k)
|
||||
|
|
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
|
|||
trailers = trailers,
|
||||
requestOnClick = {
|
||||
item.id?.let { id ->
|
||||
if (request4kEnabled) {
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
fromLongClick = false,
|
||||
title = item.name ?: "",
|
||||
items =
|
||||
listOf(
|
||||
DialogItem(
|
||||
text = requestStr,
|
||||
onClick = { viewModel.request(id, false) },
|
||||
),
|
||||
DialogItem(
|
||||
text = request4kStr,
|
||||
onClick = { viewModel.request(id, true) },
|
||||
),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
viewModel.request(id, false)
|
||||
}
|
||||
showRequestSeasonDialog = true
|
||||
}
|
||||
},
|
||||
cancelOnClick = {
|
||||
|
|
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
|
|||
waitToLoad = params.fromLongClick,
|
||||
)
|
||||
}
|
||||
if (showRequestSeasonDialog) {
|
||||
RequestSeasonsDialog(
|
||||
title = item?.name ?: "",
|
||||
seasons = seasons,
|
||||
request4kEnabled = request4kEnabled,
|
||||
onSubmit = { seasons, is4k ->
|
||||
item?.id?.let { viewModel.request(it, seasons, is4k) }
|
||||
showRequestSeasonDialog = false
|
||||
},
|
||||
onDismissRequest = { showRequestSeasonDialog = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
series: TvDetails,
|
||||
rating: DiscoverRating?,
|
||||
canCancel: Boolean,
|
||||
seasons: List<Season>,
|
||||
seasons: List<RequestSeason>,
|
||||
similar: List<DiscoverItem>,
|
||||
recommended: List<DiscoverItem>,
|
||||
trailers: List<Trailer>,
|
||||
|
|
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
SeerrAvailability.from(series.mediaInfo?.status)
|
||||
?: SeerrAvailability.UNKNOWN,
|
||||
requestOnClick = requestOnClick,
|
||||
pendingOnClick = requestOnClick,
|
||||
cancelOnClick = cancelOnClick,
|
||||
moreOnClick = moreOnClick,
|
||||
goToOnClick = goToOnClick,
|
||||
|
|
|
|||
|
|
@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
|
|||
import androidx.lifecycle.viewModelScope
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.Season
|
||||
import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
|
|
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
|
|||
val tvSeries = MutableLiveData<TvDetails?>(null)
|
||||
val rating = MutableLiveData<DiscoverRating?>(null)
|
||||
|
||||
val seasons = MutableLiveData<List<Season>>(listOf())
|
||||
val seasons = MutableLiveData<List<RequestSeason>>(listOf())
|
||||
val trailers = MutableLiveData<List<Trailer>>(listOf())
|
||||
val people = MutableLiveData<List<DiscoverItem>>(listOf())
|
||||
val similar = MutableLiveData<List<DiscoverItem>>()
|
||||
|
|
@ -100,9 +102,10 @@ class DiscoverSeriesViewModel
|
|||
) {
|
||||
Timber.v("Init for tv %s", item.id)
|
||||
val tv = fetchAndSetItem().await()
|
||||
val discoveredItem = DiscoverItem(tv)
|
||||
val discoveredItem = seerrService.createDiscoverItem(tv)
|
||||
backdropService.submit(discoveredItem)
|
||||
|
||||
updateSeasonStatus()
|
||||
updateCanCancel()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
|
|
@ -118,7 +121,7 @@ class DiscoverSeriesViewModel
|
|||
seerrService.api.tvApi
|
||||
.tvTvIdSimilarGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
similar.setValueOnMain(result)
|
||||
}
|
||||
|
|
@ -127,7 +130,7 @@ class DiscoverSeriesViewModel
|
|||
seerrService.api.tvApi
|
||||
.tvTvIdRecommendationsGet(tvId = item.id, page = 1)
|
||||
.results
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
recommended.setValueOnMain(result)
|
||||
}
|
||||
|
|
@ -135,11 +138,11 @@ class DiscoverSeriesViewModel
|
|||
val people =
|
||||
tv.credits
|
||||
?.cast
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty() +
|
||||
tv.credits
|
||||
?.crew
|
||||
?.map(::DiscoverItem)
|
||||
?.map { seerrService.createDiscoverItem(it) }
|
||||
.orEmpty()
|
||||
this@DiscoverSeriesViewModel.people.setValueOnMain(people)
|
||||
|
||||
|
|
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
|
|||
navigationManager.navigateTo(destination)
|
||||
}
|
||||
|
||||
private suspend fun updateSeasonStatus() {
|
||||
tvSeries.value?.let { tv ->
|
||||
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
|
||||
tv.seasons?.forEach {
|
||||
it.seasonNumber?.let {
|
||||
seasonStatus[it] = SeerrAvailability.UNKNOWN
|
||||
}
|
||||
}
|
||||
val tvStatus =
|
||||
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
|
||||
tv.mediaInfo
|
||||
?.requests
|
||||
?.forEach {
|
||||
it.seasons?.mapNotNull { season ->
|
||||
season.seasonNumber?.let {
|
||||
val current = seasonStatus[season.seasonNumber]
|
||||
val new =
|
||||
SeerrAvailability
|
||||
.from(season.status)
|
||||
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
|
||||
if (current == null || new.status > current.status) {
|
||||
seasonStatus[season.seasonNumber] = new
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Timber.v("seasonStatus=%s", seasonStatus)
|
||||
val requestSeasons =
|
||||
seasonStatus.mapNotNull { (seasonNumber, availability) ->
|
||||
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
|
||||
RequestSeason(it, availability)
|
||||
}
|
||||
}
|
||||
seasons.setValueOnMain(requestSeasons)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateCanCancel() {
|
||||
val user = userConfig.firstOrNull()
|
||||
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
|
||||
|
|
@ -165,20 +205,43 @@ class DiscoverSeriesViewModel
|
|||
|
||||
fun request(
|
||||
id: Int,
|
||||
seasons: Set<Int>,
|
||||
is4k: Boolean,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val request =
|
||||
seerrService.api.requestApi.requestPost(
|
||||
RequestPostRequest(
|
||||
is4k = is4k,
|
||||
mediaId = id,
|
||||
mediaType = RequestPostRequest.MediaType.TV,
|
||||
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons
|
||||
),
|
||||
)
|
||||
fetchAndSetItem().await()
|
||||
updateCanCancel()
|
||||
tvSeries.value?.let { tv ->
|
||||
val currentRequest =
|
||||
tv.mediaInfo?.requests?.firstOrNull {
|
||||
it.requestedBy?.id ==
|
||||
seerrServerRepository.currentUserId.first()
|
||||
}
|
||||
if (currentRequest != null) {
|
||||
Timber.v("User has pending request, will update")
|
||||
seerrService.api.requestApi.requestRequestIdPut(
|
||||
requestId = currentRequest.id.toString(),
|
||||
requestRequestIdPutRequest =
|
||||
RequestRequestIdPutRequest(
|
||||
is4k = is4k,
|
||||
mediaType = RequestRequestIdPutRequest.MediaType.TV,
|
||||
seasons = seasons.toList(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Timber.v("New request for %s seasons", seasons.size)
|
||||
seerrService.api.requestApi.requestPost(
|
||||
RequestPostRequest(
|
||||
is4k = is4k,
|
||||
mediaId = id,
|
||||
mediaType = RequestPostRequest.MediaType.TV,
|
||||
seasons = seasons.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fetchAndSetItem().await()
|
||||
updateSeasonStatus()
|
||||
updateCanCancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
|
|||
trailerOnClick: (Trailer) -> Unit,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
pendingOnClick: () -> Unit = {},
|
||||
) {
|
||||
val firstFocus = remember { FocusRequester() }
|
||||
LazyRow(
|
||||
|
|
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
|
|||
SeerrAvailability.PENDING,
|
||||
SeerrAvailability.PROCESSING,
|
||||
-> {
|
||||
// TODO?
|
||||
pendingOnClick.invoke()
|
||||
}
|
||||
|
||||
SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||
|
|
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
|
|||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) {
|
||||
item("request_partial") {
|
||||
ExpandableFaButton(
|
||||
title = R.string.request,
|
||||
iconStringRes = R.string.fa_download,
|
||||
onClick = {
|
||||
requestOnClick.invoke()
|
||||
},
|
||||
enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE,
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged(buttonOnFocusChanged),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (canCancel) {
|
||||
item("cancel") {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -82,8 +83,16 @@ fun EpisodeDetails(
|
|||
var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
|
|
@ -98,6 +107,7 @@ fun EpisodeDetails(
|
|||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
|
||||
when (val state = loading) {
|
||||
|
|
@ -197,6 +207,7 @@ fun EpisodeDetails(
|
|||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -215,6 +226,7 @@ fun EpisodeDetails(
|
|||
onClearChosenStreams = {
|
||||
viewModel.clearChosenStreams(chosenStreams)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
@ -224,6 +236,8 @@ fun EpisodeDetails(
|
|||
favoriteOnClick = {
|
||||
viewModel.setFavorite(ep.id, !ep.favorite)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
deleteOnClick = { showDeleteDialog = ep },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -276,6 +290,16 @@ fun EpisodeDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -290,6 +314,8 @@ fun EpisodeDetailsContent(
|
|||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
moreOnClick: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -347,6 +373,8 @@ fun EpisodeDetailsContent(
|
|||
},
|
||||
trailers = null,
|
||||
trailerOnClick = {},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback
|
|||
import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||
|
|
@ -54,6 +56,7 @@ class EpisodeViewModel
|
|||
private val favoriteWatchManager: FavoriteWatchManager,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
@Assisted val itemId: UUID,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -65,6 +68,9 @@ class EpisodeViewModel
|
|||
val item = MutableLiveData<BaseItem?>(null)
|
||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
|
||||
var canDelete: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
|
@ -95,6 +101,7 @@ class EpisodeViewModel
|
|||
) {
|
||||
val prefs = userPreferencesService.getCurrent()
|
||||
val item = fetchAndSetItem().await()
|
||||
canDelete = mediaManagementService.canDelete(item)
|
||||
val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs)
|
||||
withContext(Dispatchers.Main) {
|
||||
this@EpisodeViewModel.item.value = item
|
||||
|
|
@ -199,4 +206,10 @@ class EpisodeViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(item: BaseItem) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
|
|||
import android.text.format.DateUtils
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -140,7 +142,11 @@ fun TvGuideGrid(
|
|||
.fillMaxHeight(.30f),
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(focusedPosition.row < 1) {
|
||||
AnimatedVisibility(
|
||||
focusedPosition.row < 1,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
ExpandableFaButton(
|
||||
title = R.string.view_options,
|
||||
iconStringRes = R.string.fa_sliders,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -111,6 +112,14 @@ fun MovieDetails(
|
|||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
|
||||
val moreActions =
|
||||
MoreDialogActions(
|
||||
|
|
@ -126,6 +135,7 @@ fun MovieDetails(
|
|||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = { showDeleteDialog = it },
|
||||
)
|
||||
|
||||
when (val state = loading) {
|
||||
|
|
@ -201,6 +211,7 @@ fun MovieDetails(
|
|||
seriesId = null,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
canDelete = viewModel.canDelete,
|
||||
actions = moreActions,
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -242,6 +253,7 @@ fun MovieDetails(
|
|||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -291,6 +303,7 @@ fun MovieDetails(
|
|||
playbackPosition = similar.playbackPosition,
|
||||
watched = similar.played,
|
||||
favorite = similar.favorite,
|
||||
canDelete = false,
|
||||
actions = moreActions,
|
||||
)
|
||||
moreDialog =
|
||||
|
|
@ -310,6 +323,8 @@ fun MovieDetails(
|
|||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
canDelete = viewModel.canDelete,
|
||||
deleteOnClick = { showDeleteDialog = movie },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -362,6 +377,16 @@ fun MovieDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -395,6 +420,8 @@ fun MovieDetailsContent(
|
|||
onLongClickSimilar: (Int, BaseItem) -> Unit,
|
||||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -457,6 +484,8 @@ fun MovieDetailsContent(
|
|||
position = TRAILER_ROW
|
||||
trailerOnClick.invoke(it)
|
||||
},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
|||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ExtrasService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
|
|
@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
|
|
@ -73,6 +75,7 @@ class MovieViewModel
|
|||
private val extrasService: ExtrasService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
@Assisted val itemId: UUID,
|
||||
) : ViewModel() {
|
||||
@AssistedFactory
|
||||
|
|
@ -90,6 +93,9 @@ class MovieViewModel
|
|||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
|
||||
var canDelete: Boolean = false
|
||||
private set
|
||||
|
||||
init {
|
||||
init()
|
||||
}
|
||||
|
|
@ -106,6 +112,7 @@ class MovieViewModel
|
|||
api.userLibraryApi.getItem(itemId).content.let {
|
||||
BaseItem.from(it, api)
|
||||
}
|
||||
canDelete = mediaManagementService.canDelete(item)
|
||||
this@MovieViewModel.item.setValueOnMain(item)
|
||||
item
|
||||
}
|
||||
|
|
@ -274,4 +281,10 @@ class MovieViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(item: BaseItem) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
navigationManager.goBack()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type
|
|||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
|
|
@ -91,10 +92,25 @@ fun SearchForContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val titleRes =
|
||||
remember {
|
||||
when (searchType) {
|
||||
BaseItemKind.BOX_SET -> R.string.collections
|
||||
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
val title = titleRes?.let { stringResource(it) } ?: ""
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.search_for, title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -190,16 +206,8 @@ fun SearchForContent(
|
|||
text = stringResource(R.string.no_results),
|
||||
)
|
||||
} else {
|
||||
val titleRes =
|
||||
remember {
|
||||
when (searchType) {
|
||||
BaseItemKind.BOX_SET -> R.string.collections
|
||||
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
ItemRow(
|
||||
title = titleRes?.let { stringResource(it) } ?: "",
|
||||
title = "",
|
||||
items = st.items,
|
||||
onClickItem = { _, item -> onClick.invoke(item) },
|
||||
onLongClickItem = { _, _ -> },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ fun FocusedEpisodeFooter(
|
|||
moreOnClick: () -> Unit,
|
||||
watchOnClick: () -> Unit,
|
||||
favoriteOnClick: () -> Unit,
|
||||
canDelete: Boolean,
|
||||
deleteOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
buttonOnFocusChanged: (FocusState) -> Unit = {},
|
||||
) {
|
||||
|
|
@ -47,6 +49,8 @@ fun FocusedEpisodeFooter(
|
|||
buttonOnFocusChanged = buttonOnFocusChanged,
|
||||
trailers = null,
|
||||
trailerOnClick = {},
|
||||
canDelete = canDelete,
|
||||
deleteOnClick = deleteOnClick,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series
|
|||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusState
|
||||
|
|
@ -31,17 +32,17 @@ fun FocusedEpisodeHeader(
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = modifier,
|
||||
) {
|
||||
EpisodeName(dto, modifier = Modifier)
|
||||
EpisodeName(dto, modifier = Modifier.padding(start = 8.dp))
|
||||
|
||||
ep?.ui?.quickDetails?.let {
|
||||
QuickDetails(it, ep.timeRemainingOrRuntime)
|
||||
QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp))
|
||||
}
|
||||
|
||||
if (dto != null) {
|
||||
VideoStreamDetails(
|
||||
chosenStreams = chosenStreams,
|
||||
numberOfVersions = dto.mediaSourceCount ?: 0,
|
||||
modifier = Modifier,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
OverviewText(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.series
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
|
|
@ -14,6 +17,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
|
|
@ -24,12 +28,14 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
|
@ -53,7 +59,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
|||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
|
|
@ -76,6 +84,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
|||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
|
|
@ -102,21 +111,26 @@ fun SeriesDetails(
|
|||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||
|
||||
val item by viewModel.item.observeAsState()
|
||||
val canDelete by viewModel.canDeleteSeries.collectAsState()
|
||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||
val extras by viewModel.extras.observeAsState(listOf())
|
||||
val people by viewModel.people.observeAsState(listOf())
|
||||
val similar by viewModel.similar.observeAsState(listOf())
|
||||
val discovered by viewModel.discovered.collectAsState()
|
||||
val discoverSeries by viewModel.discoverSeries.collectAsState()
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
|
|
@ -150,6 +164,7 @@ fun SeriesDetails(
|
|||
similar = similar,
|
||||
played = played,
|
||||
favorite = item.data.userData?.isFavorite ?: false,
|
||||
canDelete = canDelete,
|
||||
modifier = modifier,
|
||||
onClickItem = { index, item ->
|
||||
viewModel.navigateTo(item.destination())
|
||||
|
|
@ -163,23 +178,29 @@ fun SeriesDetails(
|
|||
)
|
||||
},
|
||||
onLongClickItem = { index, season ->
|
||||
seasonDialog =
|
||||
buildDialogForSeason(
|
||||
context = context,
|
||||
s = season,
|
||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||
markPlayed = { played ->
|
||||
viewModel.setSeasonWatched(season.id, played)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = season.id,
|
||||
shuffle = shuffle,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
scope.launchDefault {
|
||||
seasonDialog =
|
||||
buildDialogForSeason(
|
||||
context = context,
|
||||
s = season,
|
||||
canDelete = viewModel.canDelete(season),
|
||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||
markPlayed = { played ->
|
||||
viewModel.setSeasonWatched(season.id, played)
|
||||
},
|
||||
onClickPlay = { shuffle ->
|
||||
viewModel.navigateTo(
|
||||
Destination.PlaybackList(
|
||||
itemId = season.id,
|
||||
shuffle = shuffle,
|
||||
),
|
||||
)
|
||||
},
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
overviewDialog =
|
||||
|
|
@ -213,6 +234,12 @@ fun SeriesDetails(
|
|||
onClickExtra = { _, extra ->
|
||||
viewModel.navigateTo(extra.destination)
|
||||
},
|
||||
discoverSeries = discoverSeries,
|
||||
onClickDiscoverSeries = {
|
||||
discoverSeries?.let {
|
||||
viewModel.navigateTo(Destination.DiscoveredItem(it))
|
||||
}
|
||||
},
|
||||
discovered = discovered,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
|
|
@ -231,6 +258,9 @@ fun SeriesDetails(
|
|||
showPlaylistDialog.makePresent(itemId)
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
),
|
||||
)
|
||||
if (showWatchConfirmation) {
|
||||
|
|
@ -283,6 +313,19 @@ fun SeriesDetails(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.title ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
if (seasons?.lastOrNull()?.id == item.id) {
|
||||
focusManager.moveFocus(FocusDirection.Previous)
|
||||
}
|
||||
viewModel.deleteItem(item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val HEADER_ROW = 0
|
||||
|
|
@ -305,6 +348,7 @@ fun SeriesDetailsContent(
|
|||
discovered: List<DiscoverItem>,
|
||||
played: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
onClickItem: (Int, BaseItem) -> Unit,
|
||||
onClickPerson: (Person) -> Unit,
|
||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||
|
|
@ -316,6 +360,8 @@ fun SeriesDetailsContent(
|
|||
onClickExtra: (Int, ExtrasItem) -> Unit,
|
||||
moreActions: MoreDialogActions,
|
||||
onClickDiscover: (Int, DiscoverItem) -> Unit,
|
||||
discoverSeries: DiscoverItem?,
|
||||
onClickDiscoverSeries: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
|
@ -436,6 +482,42 @@ fun SeriesDetailsContent(
|
|||
}
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
DeleteButton(
|
||||
onClick = {
|
||||
position = HEADER_ROW
|
||||
moreActions.onClickDelete.invoke(series)
|
||||
},
|
||||
modifier =
|
||||
Modifier
|
||||
.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(
|
||||
visible = discoverSeries != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
ExpandableFaButton(
|
||||
title = R.string.discover,
|
||||
iconStringRes = R.string.fa_magnifying_glass_plus,
|
||||
onClick = onClickDiscoverSeries,
|
||||
modifier =
|
||||
Modifier.onFocusChanged {
|
||||
if (it.isFocused) {
|
||||
scope.launch(ExceptionHandler()) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
|
|
@ -533,6 +615,7 @@ fun SeriesDetailsContent(
|
|||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
actions = moreActions,
|
||||
canDelete = false,
|
||||
)
|
||||
moreDialog =
|
||||
DialogParams(
|
||||
|
|
@ -621,7 +704,7 @@ fun SeriesDetailsHeader(
|
|||
) {
|
||||
QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp))
|
||||
dto.genres?.letNotEmpty {
|
||||
GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp))
|
||||
GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp))
|
||||
}
|
||||
dto.overview?.let { overview ->
|
||||
OverviewText(
|
||||
|
|
@ -638,9 +721,11 @@ fun SeriesDetailsHeader(
|
|||
fun buildDialogForSeason(
|
||||
context: Context,
|
||||
s: BaseItem,
|
||||
canDelete: Boolean,
|
||||
onClickItem: (BaseItem) -> Unit,
|
||||
markPlayed: (Boolean) -> Unit,
|
||||
onClickPlay: (Boolean) -> Unit,
|
||||
onClickDelete: (BaseItem) -> Unit,
|
||||
): DialogParams {
|
||||
val items =
|
||||
buildList {
|
||||
|
|
@ -679,6 +764,17 @@ fun buildDialogForSeason(
|
|||
onClickPlay.invoke(true)
|
||||
},
|
||||
)
|
||||
if (canDelete) {
|
||||
add(
|
||||
DialogItem(
|
||||
context.getString(R.string.delete),
|
||||
Icons.Default.Delete,
|
||||
iconColor = Color.Red.copy(alpha = .8f),
|
||||
) {
|
||||
onClickDelete.invoke(s)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
return DialogParams(
|
||||
title = s.name ?: context.getString(R.string.tv_season),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
|
|
@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||
|
|
@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
|||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.rememberInt
|
||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import com.github.damontecres.wholphin.util.LoadingState
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -89,6 +93,7 @@ fun SeriesOverview(
|
|||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val firstItemFocusRequester = remember { FocusRequester() }
|
||||
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -118,20 +123,7 @@ fun SeriesOverview(
|
|||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
|
||||
var rowFocused by rememberInt()
|
||||
|
||||
LaunchedEffect(episodes) {
|
||||
episodes?.let { episodes ->
|
||||
if (episodes is EpisodeList.Success) {
|
||||
if (episodes.episodes.isNotEmpty()) {
|
||||
// TODO focus on first episode when changing seasons?
|
||||
// firstItemFocusRequester.requestFocus()
|
||||
episodes.episodes.getOrNull(position.episodeRowIndex)?.let {
|
||||
viewModel.refreshEpisode(it.id, position.episodeRowIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
LaunchedEffect(position, episodes) {
|
||||
val focusedEpisode =
|
||||
|
|
@ -146,6 +138,13 @@ fun SeriesOverview(
|
|||
}
|
||||
val chosenStreams by viewModel.chosenStreams.observeAsState(null)
|
||||
|
||||
val preferredSubtitleLanguage =
|
||||
viewModel.serverRepository.currentUserDto
|
||||
.observeAsState()
|
||||
.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
|
||||
when (val state = loading) {
|
||||
is LoadingState.Error -> {
|
||||
ErrorMessage(state, modifier)
|
||||
|
|
@ -177,7 +176,7 @@ fun SeriesOverview(
|
|||
}
|
||||
}
|
||||
|
||||
fun buildMoreForEpisode(
|
||||
suspend fun buildMoreForEpisode(
|
||||
ep: BaseItem,
|
||||
chosenStreams: ChosenStreams?,
|
||||
fromLongClick: Boolean,
|
||||
|
|
@ -194,6 +193,7 @@ fun SeriesOverview(
|
|||
seriesId = series.id,
|
||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||
canDelete = viewModel.canDelete(ep),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel::navigateTo,
|
||||
|
|
@ -216,6 +216,9 @@ fun SeriesOverview(
|
|||
showPlaylistDialog = it
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = it
|
||||
},
|
||||
),
|
||||
onChooseVersion = {
|
||||
chooseVersion =
|
||||
|
|
@ -256,6 +259,7 @@ fun SeriesOverview(
|
|||
type,
|
||||
)
|
||||
},
|
||||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -315,7 +319,9 @@ fun SeriesOverview(
|
|||
)
|
||||
},
|
||||
onLongClick = { ep ->
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
||||
scope.launchDefault {
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
||||
}
|
||||
},
|
||||
playOnClick = { resume ->
|
||||
rowFocused = EPISODE_ROW
|
||||
|
|
@ -343,18 +349,22 @@ fun SeriesOverview(
|
|||
},
|
||||
moreOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
||||
scope.launchDefault {
|
||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
||||
}
|
||||
}
|
||||
},
|
||||
overviewOnClick = {
|
||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = it.name ?: context.getString(R.string.unknown),
|
||||
overview = it.data.overview,
|
||||
genres = it.data.genres.orEmpty(),
|
||||
files = it.data.mediaSources.orEmpty(),
|
||||
)
|
||||
scope.launchDefault {
|
||||
overviewDialog =
|
||||
ItemDetailsDialogInfo(
|
||||
title = it.name ?: context.getString(R.string.unknown),
|
||||
overview = it.data.overview,
|
||||
genres = it.data.genres.orEmpty(),
|
||||
files = it.data.mediaSources.orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
personOnClick = {
|
||||
|
|
@ -367,6 +377,8 @@ fun SeriesOverview(
|
|||
),
|
||||
)
|
||||
},
|
||||
canDelete = { viewModel.canDelete(it, preferences.appPreferences) },
|
||||
deleteOnClick = { showDeleteDialog = it },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -420,6 +432,17 @@ fun SeriesOverview(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { item ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = item.subtitle ?: "",
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(item)
|
||||
episodeRowFocusRequester.tryRequestFocus()
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val EPISODE_ROW = 0
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ fun SeriesOverviewContent(
|
|||
moreOnClick: () -> Unit,
|
||||
overviewOnClick: () -> Unit,
|
||||
personOnClick: (Person) -> Unit,
|
||||
canDelete: (BaseItem) -> Boolean,
|
||||
deleteOnClick: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
|
|
@ -134,7 +136,7 @@ fun SeriesOverviewContent(
|
|||
.onFocusChanged { pageHasFocus = it.hasFocus },
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.focusGroup()
|
||||
|
|
@ -159,9 +161,10 @@ fun SeriesOverviewContent(
|
|||
Modifier
|
||||
.focusRequester(tabRowFocusRequester)
|
||||
.padding(paddingValues)
|
||||
.padding(bottom = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
SeriesName(series.name, Modifier)
|
||||
SeriesName(series.name, Modifier.padding(start = 8.dp))
|
||||
FocusedEpisodeHeader(
|
||||
preferences = preferences,
|
||||
ep = focusedEpisode,
|
||||
|
|
@ -266,6 +269,7 @@ fun SeriesOverviewContent(
|
|||
},
|
||||
interactionSource = interactionSource,
|
||||
cardHeight = 120.dp,
|
||||
useSeriesForPrimary = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -292,10 +296,12 @@ fun SeriesOverviewContent(
|
|||
}
|
||||
}
|
||||
},
|
||||
canDelete = canDelete.invoke(ep),
|
||||
deleteOnClick = { deleteOnClick.invoke(ep) },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp),
|
||||
.padding(top = 4.dp)
|
||||
.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem
|
|||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ExtrasService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
|
|
@ -24,10 +26,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
|||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.gt
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||
import com.github.damontecres.wholphin.ui.lt
|
||||
|
|
@ -53,6 +57,10 @@ import kotlinx.coroutines.Job
|
|||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
|
@ -90,6 +98,7 @@ class SeriesViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrService: SeerrService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
@Assisted val seriesId: UUID,
|
||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||
@Assisted val seriesPageType: SeriesPageType,
|
||||
|
|
@ -111,9 +120,11 @@ class SeriesViewModel
|
|||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||
val people = MutableLiveData<List<Person>>(listOf())
|
||||
val similar = MutableLiveData<List<BaseItem>>()
|
||||
val canDeleteSeries = MutableStateFlow(false)
|
||||
|
||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
|
||||
|
||||
val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
|
||||
|
||||
|
|
@ -127,6 +138,7 @@ class SeriesViewModel
|
|||
Timber.v("Start")
|
||||
addCloseable { themeSongPlayer.stop() }
|
||||
val item = fetchItem(seriesId)
|
||||
canDeleteSeries.update { mediaManagementService.canDelete(item) }
|
||||
backdropService.submit(item)
|
||||
|
||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||
|
|
@ -222,7 +234,39 @@ class SeriesViewModel
|
|||
val results = seerrService.similar(item).orEmpty()
|
||||
discovered.update { results }
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
seerrService.active.collectLatest { active ->
|
||||
val tv =
|
||||
if (active) {
|
||||
try {
|
||||
seerrService
|
||||
.getTvSeries(item)
|
||||
?.let { seerrService.createDiscoverItem(it) }
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
discoverSeries.update { tv }
|
||||
}
|
||||
}
|
||||
}
|
||||
mediaManagementService.deletedItemFlow
|
||||
.onEach { deletedItem ->
|
||||
if (deletedItem.item.data.seriesId == seriesId) {
|
||||
Timber.d(
|
||||
"Item %s deleted from series %s",
|
||||
deletedItem.item.id,
|
||||
seriesId,
|
||||
)
|
||||
val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await()
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
}
|
||||
}.catch { ex ->
|
||||
Timber.e(ex, "Error refreshing after deleted item")
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -259,9 +303,12 @@ class SeriesViewModel
|
|||
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||
listOf(
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
listOf(
|
||||
ItemFields.CAN_DELETE,
|
||||
)
|
||||
},
|
||||
)
|
||||
val pager =
|
||||
|
|
@ -300,6 +347,7 @@ class SeriesViewModel
|
|||
ItemFields.OVERVIEW,
|
||||
ItemFields.CUSTOM_RATING,
|
||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||
ItemFields.CAN_DELETE,
|
||||
),
|
||||
)
|
||||
Timber.v(
|
||||
|
|
@ -341,12 +389,6 @@ class SeriesViewModel
|
|||
withContext(Dispatchers.Main) {
|
||||
this@SeriesViewModel.episodes.value = episodes
|
||||
}
|
||||
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
|
||||
(episodes as? EpisodeList.Success)
|
||||
?.let {
|
||||
it.episodes.getOrNull(it.initialEpisodeIndex)
|
||||
}?.let { lookupPeopleInEpisode(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -528,7 +570,7 @@ class SeriesViewModel
|
|||
api.userLibraryApi
|
||||
.getItem(item.id)
|
||||
.content.people
|
||||
?.map { Person.fromDto(it, api) }
|
||||
?.map { Person.fromDto(context, it, api) }
|
||||
.orEmpty()
|
||||
|
||||
PeopleInItem(item.id, list)
|
||||
|
|
@ -551,6 +593,64 @@ class SeriesViewModel
|
|||
lookUpChosenTracks(item.id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(item: BaseItem) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
if (item.type == BaseItemKind.SERIES) {
|
||||
navigationManager.goBack()
|
||||
} else if (seriesPageType == SeriesPageType.DETAILS) {
|
||||
this@SeriesViewModel.item.value?.let { series ->
|
||||
val seasons = getSeasons(series, null).await()
|
||||
if (seasons.isEmpty()) {
|
||||
navigationManager.goBack()
|
||||
} else {
|
||||
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
position.value.let { (_, episodeIndex) ->
|
||||
val eps = episodes.value as? EpisodeList.Success
|
||||
if (eps != null) {
|
||||
val pager = eps.episodes
|
||||
val lastIndex = pager.lastIndex
|
||||
pager.refreshPagesAfter(episodeIndex)
|
||||
if (pager.isEmpty()) {
|
||||
navigationManager.goBack()
|
||||
} else {
|
||||
if (episodeIndex == lastIndex) {
|
||||
// Deleted last episode, so need to move left
|
||||
episodes.setValueOnMain(
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex - 1,
|
||||
),
|
||||
)
|
||||
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
|
||||
} else {
|
||||
episodes.setValueOnMain(
|
||||
EpisodeList.Success(
|
||||
eps.seasonId,
|
||||
pager,
|
||||
episodeIndex,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
sealed interface EpisodeList {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
package com.github.damontecres.wholphin.ui.discover
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -60,8 +58,8 @@ fun DiscoverPage(
|
|||
) {
|
||||
AnimatedVisibility(
|
||||
showHeader,
|
||||
enter = slideInVertically() + fadeIn(),
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically(),
|
||||
) {
|
||||
TabRow(
|
||||
selectedTabIndex = selectedTabIndex,
|
||||
|
|
|
|||
|
|
@ -85,13 +85,13 @@ class SeerrRequestsViewModel
|
|||
seerrService.api.moviesApi
|
||||
.movieMovieIdGet(
|
||||
movieId = request.media.tmdbId,
|
||||
).let { DiscoverItem(it) }
|
||||
).let { seerrService.createDiscoverItem(it) }
|
||||
}
|
||||
|
||||
SeerrItemType.TV -> {
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdGet(tvId = request.media.tmdbId)
|
||||
.let { DiscoverItem(it) }
|
||||
.let { seerrService.createDiscoverItem(it) }
|
||||
}
|
||||
|
||||
SeerrItemType.PERSON -> {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
|
|||
import com.github.damontecres.wholphin.ui.cards.GenreCard
|
||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.CircularProgress
|
||||
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||
import com.github.damontecres.wholphin.ui.components.EpisodeName
|
||||
|
|
@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
|||
import com.github.damontecres.wholphin.ui.components.FocusableItemRow
|
||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||
import com.github.damontecres.wholphin.ui.components.RowColumnItem
|
||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||
|
|
@ -116,6 +118,7 @@ fun HomePage(
|
|||
LoadingState.Success -> {
|
||||
var dialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||
var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) }
|
||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||
var position by rememberPosition()
|
||||
HomePageContent(
|
||||
|
|
@ -136,6 +139,7 @@ fun HomePage(
|
|||
playbackPosition = item.playbackPosition,
|
||||
watched = item.played,
|
||||
favorite = item.favorite,
|
||||
canDelete = viewModel.canDelete(item, preferences.appPreferences),
|
||||
actions =
|
||||
MoreDialogActions(
|
||||
navigateTo = viewModel.navigationManager::navigateTo,
|
||||
|
|
@ -150,6 +154,9 @@ fun HomePage(
|
|||
showPlaylistDialog = itemId
|
||||
},
|
||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||
onClickDelete = {
|
||||
showDeleteDialog = RowColumnItem(position, item)
|
||||
},
|
||||
),
|
||||
)
|
||||
dialog =
|
||||
|
|
@ -190,6 +197,16 @@ fun HomePage(
|
|||
elevation = 3.dp,
|
||||
)
|
||||
}
|
||||
showDeleteDialog?.let { (position, item) ->
|
||||
ConfirmDeleteDialog(
|
||||
itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "),
|
||||
onCancel = { showDeleteDialog = null },
|
||||
onConfirm = {
|
||||
viewModel.deleteItem(position, item)
|
||||
showDeleteDialog = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -524,6 +541,7 @@ fun HomePageCardContent(
|
|||
onLongClick = onLongClick,
|
||||
modifier = modifier,
|
||||
cardHeight = viewOptions.heightDp.dp,
|
||||
useSeriesForPrimary = viewOptions.useSeries,
|
||||
)
|
||||
} else {
|
||||
BannerCard(
|
||||
|
|
@ -543,6 +561,7 @@ fun HomePageCardContent(
|
|||
modifier = modifier,
|
||||
interactionSource = null,
|
||||
cardHeight = viewOptions.heightDp.dp,
|
||||
useSeriesForPrimary = viewOptions.useSeries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.DatePlayedService
|
||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||
import com.github.damontecres.wholphin.services.HomePageResolvedSettings
|
||||
import com.github.damontecres.wholphin.services.HomeSettingsService
|
||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.MediaReportService
|
||||
import com.github.damontecres.wholphin.services.NavDrawerService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.services.deleteItem
|
||||
import com.github.damontecres.wholphin.services.tvAccess
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
|
|
@ -52,6 +57,7 @@ class HomeViewModel
|
|||
private val datePlayedService: DatePlayedService,
|
||||
private val backdropService: BackdropService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(HomeState.EMPTY)
|
||||
val state: StateFlow<HomeState> = _state
|
||||
|
|
@ -78,6 +84,8 @@ class HomeViewModel
|
|||
// Refreshing if a load has already occurred and the rows haven't significantly changed
|
||||
val refresh =
|
||||
state.loadingState == LoadingState.Success && state.settings == settings
|
||||
Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}")
|
||||
_state.update { it.copy(settings = settings) }
|
||||
|
||||
val semaphore = Semaphore(4)
|
||||
|
||||
|
|
@ -102,6 +110,7 @@ class HomeViewModel
|
|||
userDto = userDto,
|
||||
libraries = libraries,
|
||||
limit = prefs.maxItemsPerRow,
|
||||
isRefresh = refresh,
|
||||
)
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error on row %s", row)
|
||||
|
|
@ -186,6 +195,36 @@ class HomeViewModel
|
|||
backdropService.submit(item)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteItem(
|
||||
position: RowColumn,
|
||||
item: BaseItem,
|
||||
) {
|
||||
deleteItem(context, mediaManagementService, item) {
|
||||
viewModelScope.launchDefault {
|
||||
val row = state.value.homeRows.getOrNull(position.row)
|
||||
if (row is HomeRowLoadingState.Success) {
|
||||
_state.update {
|
||||
val newRow =
|
||||
row.items.toMutableList().apply {
|
||||
removeAt(position.column)
|
||||
}
|
||||
it.copy(
|
||||
homeRows =
|
||||
it.homeRows.toMutableList().apply {
|
||||
set(position.row, row.copy(items = newRow))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun canDelete(
|
||||
item: BaseItem,
|
||||
appPreferences: AppPreferences,
|
||||
): Boolean = mediaManagementService.canDelete(item, appPreferences)
|
||||
}
|
||||
|
||||
data class HomeState(
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ class SearchViewModel
|
|||
val results =
|
||||
seerrService
|
||||
.search(query)
|
||||
.map { DiscoverItem(it) }
|
||||
.map { seerrService.createDiscoverItem(it) }
|
||||
.filter { it.type == SeerrItemType.MOVIE || it.type == SeerrItemType.TV }
|
||||
seerrResults.setValueOnMain(SearchResult.SuccessSeerr(results))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import com.github.damontecres.wholphin.ui.AspectRatio
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
import com.github.damontecres.wholphin.ui.components.ViewOptionImageType
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
|
@ -81,7 +80,7 @@ data class HomeRowPresets(
|
|||
contentScale = PrefContentScale.FIT,
|
||||
),
|
||||
liveTv = HomeRowViewOptions.liveTvDefault,
|
||||
genreSize = Cards.HEIGHT_2X3_DP,
|
||||
genreSize = HomeRowViewOptions.genreDefault.heightDp,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +122,7 @@ data class HomeRowPresets(
|
|||
)
|
||||
}
|
||||
|
||||
val Thumbnails by lazy {
|
||||
val SeriesThumbs by lazy {
|
||||
val height = 148
|
||||
val epHeight = 100
|
||||
HomeRowPresets(
|
||||
|
|
@ -132,6 +131,7 @@ data class HomeRowPresets(
|
|||
heightDp = epHeight,
|
||||
imageType = ViewOptionImageType.THUMB,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
useSeries = true,
|
||||
episodeImageType = ViewOptionImageType.THUMB,
|
||||
episodeAspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
|
|
@ -164,6 +164,50 @@ data class HomeRowPresets(
|
|||
genreSize = epHeight,
|
||||
)
|
||||
}
|
||||
|
||||
val EpisodeThumbnails by lazy {
|
||||
val height = 148
|
||||
val epHeight = 100
|
||||
HomeRowPresets(
|
||||
continueWatching =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
imageType = ViewOptionImageType.THUMB,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
showTitles = true,
|
||||
useSeries = false,
|
||||
episodeImageType = ViewOptionImageType.PRIMARY,
|
||||
episodeAspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
movieLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
tvLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = height,
|
||||
),
|
||||
videoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
),
|
||||
photoLibrary =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.WIDE,
|
||||
contentScale = PrefContentScale.CROP,
|
||||
),
|
||||
playlist =
|
||||
HomeRowViewOptions(
|
||||
heightDp = epHeight,
|
||||
aspectRatio = AspectRatio.SQUARE,
|
||||
contentScale = PrefContentScale.FIT,
|
||||
),
|
||||
liveTv = HomeRowViewOptions.liveTvDefault,
|
||||
genreSize = epHeight,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -173,13 +217,13 @@ fun HomeRowPresetsContent(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val presets =
|
||||
remember {
|
||||
listOf(
|
||||
"Wholphin Default",
|
||||
"Wholphin Compact",
|
||||
"Thumbnails",
|
||||
)
|
||||
}
|
||||
listOf(
|
||||
stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault,
|
||||
stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact,
|
||||
stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs,
|
||||
stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails,
|
||||
)
|
||||
|
||||
val focusRequesters = remember { List(presets.size) { FocusRequester() } }
|
||||
LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() }
|
||||
Column(modifier = modifier) {
|
||||
|
|
@ -192,16 +236,12 @@ fun HomeRowPresetsContent(
|
|||
.fillMaxHeight()
|
||||
.focusRestorer(focusRequesters[0]),
|
||||
) {
|
||||
itemsIndexed(presets) { index, title ->
|
||||
itemsIndexed(presets) { index, (title, preset) ->
|
||||
HomeSettingsListItem(
|
||||
selected = false,
|
||||
headlineText = title,
|
||||
onClick = {
|
||||
when (index) {
|
||||
0 -> onApply.invoke(HomeRowPresets.WholphinDefault)
|
||||
1 -> onApply.invoke(HomeRowPresets.WholphinCompact)
|
||||
2 -> onApply.invoke(HomeRowPresets.Thumbnails)
|
||||
}
|
||||
onApply.invoke(preset)
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -240,6 +240,7 @@ fun HomeSettingsPage(
|
|||
onClick = { type ->
|
||||
addRow { viewModel.addFavoriteRow(type) }
|
||||
},
|
||||
modifier = destModifier,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ fun HomeSettingsRowList(
|
|||
position = 2
|
||||
onClickPresets.invoke()
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequesters[1]),
|
||||
modifier = Modifier.focusRequester(focusRequesters[2]),
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ class HomeSettingsViewModel
|
|||
userDto = userDto,
|
||||
libraries = state.libraries,
|
||||
limit = limit,
|
||||
isRefresh = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ import javax.inject.Inject
|
|||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
// Top scrim configuration for text readability (clock, season tabs)
|
||||
private const val TOP_SCRIM_ALPHA = 0.55f
|
||||
private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
|
||||
const val TOP_SCRIM_ALPHA = 0.55f
|
||||
const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height
|
||||
|
||||
@HiltViewModel
|
||||
class ApplicationContentViewModel
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ fun DestinationContent(
|
|||
)
|
||||
}
|
||||
|
||||
BaseItemKind.VIDEO -> {
|
||||
BaseItemKind.VIDEO,
|
||||
BaseItemKind.MUSIC_VIDEO,
|
||||
-> {
|
||||
// TODO Use VideoDetails
|
||||
MovieDetails(
|
||||
preferences,
|
||||
|
|
@ -207,7 +209,7 @@ fun DestinationContent(
|
|||
CollectionFolderPhotoAlbum(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
recursive = true,
|
||||
recursive = false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
|
@ -385,10 +387,19 @@ fun CollectionFolder(
|
|||
}
|
||||
|
||||
CollectionType.HOMEVIDEOS,
|
||||
CollectionType.PHOTOS,
|
||||
-> {
|
||||
CollectionFolderPhotoAlbum(
|
||||
preferences = preferences,
|
||||
itemId = destination.itemId,
|
||||
recursive = recursiveOverride ?: false,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
CollectionType.MUSICVIDEOS,
|
||||
CollectionType.MUSIC,
|
||||
CollectionType.BOOKS,
|
||||
CollectionType.PHOTOS,
|
||||
-> {
|
||||
CollectionFolderGeneric(
|
||||
preferences,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -102,7 +102,7 @@ fun DownloadSubtitlesContent(
|
|||
.padding(PaddingValues(24.dp)),
|
||||
) {
|
||||
Text(
|
||||
text = "Search & download subtitles",
|
||||
text = stringResource(R.string.search_and_download_subtitles),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -113,7 +113,7 @@ fun DownloadSubtitlesContent(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Language",
|
||||
text = stringResource(R.string.language),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -137,7 +137,7 @@ fun DownloadSubtitlesContent(
|
|||
}
|
||||
if (dialogItems.isEmpty()) {
|
||||
Text(
|
||||
text = "No remote subtitles were found",
|
||||
text = stringResource(R.string.no_subtitles_found),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.preferences.PrefContentScale
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
|
||||
val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0")
|
||||
|
||||
val playbackScaleOptions =
|
||||
mapOf(
|
||||
ContentScale.Fit to "Fit",
|
||||
ContentScale.None to "None",
|
||||
ContentScale.Crop to "Crop",
|
||||
ContentScale.Fit to R.string.content_scale_fit,
|
||||
ContentScale.None to R.string.none,
|
||||
ContentScale.Crop to R.string.content_scale_crop,
|
||||
// ContentScale.Inside to "Inside",
|
||||
ContentScale.FillBounds to "Fill",
|
||||
ContentScale.FillWidth to "Fill Width",
|
||||
ContentScale.FillHeight to "Fill Height",
|
||||
ContentScale.FillBounds to R.string.content_scale_fill,
|
||||
ContentScale.FillWidth to R.string.content_scale_fill_width,
|
||||
ContentScale.FillHeight to R.string.content_scale_fill_height,
|
||||
)
|
||||
|
||||
val PrefContentScale.scale: ContentScale
|
||||
|
|
@ -81,3 +83,20 @@ val BaseItemKind.playable: Boolean
|
|||
BaseItemKind.YEAR,
|
||||
-> false
|
||||
}
|
||||
|
||||
fun getTypeFor(collectionType: CollectionType): BaseItemKind? =
|
||||
when (collectionType) {
|
||||
CollectionType.UNKNOWN -> null
|
||||
CollectionType.MOVIES -> BaseItemKind.MOVIE
|
||||
CollectionType.TVSHOWS -> BaseItemKind.SERIES
|
||||
CollectionType.MUSIC -> BaseItemKind.AUDIO
|
||||
CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO
|
||||
CollectionType.TRAILERS -> BaseItemKind.TRAILER
|
||||
CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO
|
||||
CollectionType.BOXSETS -> BaseItemKind.BOX_SET
|
||||
CollectionType.BOOKS -> BaseItemKind.BOOK
|
||||
CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM
|
||||
CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL
|
||||
CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST
|
||||
CollectionType.FOLDERS -> BaseItemKind.FOLDER
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import androidx.annotation.OptIn
|
|||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
|
|
@ -24,7 +23,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -37,7 +35,6 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
|
|
@ -64,6 +61,7 @@ import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
|||
import com.github.damontecres.wholphin.ui.components.Button
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.components.TextButton
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.seekBack
|
||||
import com.github.damontecres.wholphin.ui.seekForward
|
||||
import com.github.damontecres.wholphin.ui.skipStringRes
|
||||
|
|
@ -470,6 +468,14 @@ fun <T> BottomDialog(
|
|||
gravity: Int,
|
||||
currentChoice: BottomDialogItem<T>? = null,
|
||||
) {
|
||||
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
|
||||
if (currentChoice != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
choices.indexOfFirstOrNull { it == currentChoice }?.let {
|
||||
focusRequesters.getOrNull(it)?.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO enforcing a width ends up ignore the gravity
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
|
@ -504,6 +510,7 @@ fun <T> BottomDialog(
|
|||
val interactionSource = remember { MutableInteractionSource() }
|
||||
ListItem(
|
||||
selected = choice == currentChoice,
|
||||
enabled = choice.enabled,
|
||||
onClick = {
|
||||
onDismissRequest()
|
||||
onSelectChoice(index, choice)
|
||||
|
|
@ -524,6 +531,7 @@ fun <T> BottomDialog(
|
|||
}
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -539,6 +547,7 @@ data class BottomDialogItem<T>(
|
|||
val data: T,
|
||||
val headline: String,
|
||||
val supporting: String?,
|
||||
val enabled: Boolean = true,
|
||||
)
|
||||
|
||||
@PreviewTvSpec
|
||||
|
|
|
|||
|
|
@ -14,9 +14,12 @@ import androidx.compose.foundation.lazy.itemsIndexed
|
|||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -32,6 +35,8 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||
import com.github.damontecres.wholphin.ui.AppColors
|
||||
import com.github.damontecres.wholphin.ui.components.SelectedLeadingContent
|
||||
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
|
||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||
import kotlin.time.Duration
|
||||
|
||||
enum class PlaybackDialogType {
|
||||
|
|
@ -54,6 +59,7 @@ data class PlaybackSettings(
|
|||
val contentScale: ContentScale,
|
||||
val subtitleDelay: Duration,
|
||||
val hasSubtitleDownloadPermission: Boolean,
|
||||
val playbackSpeedEnabled: Boolean,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -137,6 +143,7 @@ fun PlaybackDialog(
|
|||
data = PlaybackDialogType.PLAYBACK_SPEED,
|
||||
headline = stringResource(R.string.playback_speed),
|
||||
supporting = settings.playbackSpeed.toString(),
|
||||
enabled = settings.playbackSpeedEnabled,
|
||||
),
|
||||
)
|
||||
if (enableVideoScale) {
|
||||
|
|
@ -144,7 +151,9 @@ fun PlaybackDialog(
|
|||
BottomDialogItem(
|
||||
data = PlaybackDialogType.VIDEO_SCALE,
|
||||
headline = stringResource(R.string.video_scale),
|
||||
supporting = playbackScaleOptions[settings.contentScale],
|
||||
supporting =
|
||||
playbackScaleOptions[settings.contentScale]
|
||||
?.let { stringResource(it) },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -220,7 +229,7 @@ fun PlaybackDialog(
|
|||
playbackScaleOptions.map { (scale, name) ->
|
||||
BottomDialogItem(
|
||||
data = scale,
|
||||
headline = name,
|
||||
headline = stringResource(name),
|
||||
supporting = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -393,6 +402,14 @@ fun StreamChoiceBottomDialog(
|
|||
gravity: Int,
|
||||
currentChoice: Int? = null,
|
||||
) {
|
||||
val focusRequesters = remember(choices.size) { List(choices.size) { FocusRequester() } }
|
||||
if (currentChoice != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
choices.indexOfFirstOrNull { it.index == currentChoice }?.let {
|
||||
focusRequesters.getOrNull(it)?.tryRequestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO enforcing a width ends up ignore the gravity
|
||||
Dialog(
|
||||
onDismissRequest = onDismissRequest,
|
||||
|
|
@ -443,6 +460,7 @@ fun StreamChoiceBottomDialog(
|
|||
if (choice.streamTitle != null) Text(choice.displayTitle)
|
||||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier.focusRequester(focusRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class PlaybackKeyHandler(
|
|||
private val skipWithLeftRight: Boolean,
|
||||
private val seekBack: Duration,
|
||||
private val seekForward: Duration,
|
||||
private val getDurationMs: () -> Long,
|
||||
private val controllerViewState: ControllerViewState,
|
||||
private val updateSkipIndicator: (Long) -> Unit,
|
||||
private val skipBackOnResume: Duration?,
|
||||
|
|
@ -28,15 +29,22 @@ class PlaybackKeyHandler(
|
|||
private val onStop: () -> Unit,
|
||||
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
|
||||
) {
|
||||
private var leftHandledByRepeat = false
|
||||
private var rightHandledByRepeat = false
|
||||
|
||||
fun onKeyEvent(it: KeyEvent): Boolean {
|
||||
if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
|
||||
|
||||
var result = true
|
||||
if (!controlsEnabled) {
|
||||
result = false
|
||||
return false
|
||||
} else if (handleHoldSkip(it)) {
|
||||
return true
|
||||
} else if (it.type != KeyEventType.KeyUp) {
|
||||
result = false
|
||||
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
||||
return false
|
||||
}
|
||||
|
||||
var result = true
|
||||
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
|
||||
if (!controllerViewState.controlsVisible) {
|
||||
if (skipWithLeftRight && isSkipBack(it)) {
|
||||
updateSkipIndicator(-seekBack.inWholeMilliseconds)
|
||||
|
|
@ -111,4 +119,84 @@ class PlaybackKeyHandler(
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun handleHoldSkip(event: KeyEvent): Boolean {
|
||||
if (
|
||||
controllerViewState.controlsVisible ||
|
||||
!skipWithLeftRight ||
|
||||
(!isSkipBack(event) && !isSkipForward(event))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
val isBack = isSkipBack(event)
|
||||
return when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
return true
|
||||
}
|
||||
val multiplier =
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
||||
durationMs = normalizedDurationMs(),
|
||||
)
|
||||
setHandledByRepeat(isBack = isBack, handled = true)
|
||||
seekWithMultiplier(isBack = isBack, multiplier = multiplier)
|
||||
} else {
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!handledByRepeat(isBack = isBack)) {
|
||||
seekWithMultiplier(isBack = isBack, multiplier = 1)
|
||||
}
|
||||
setHandledByRepeat(isBack = isBack, handled = false)
|
||||
true
|
||||
}
|
||||
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekWithMultiplier(
|
||||
isBack: Boolean,
|
||||
multiplier: Int,
|
||||
) {
|
||||
if (isBack) {
|
||||
val skipDuration = seekBack * multiplier
|
||||
player.seekBack(skipDuration)
|
||||
updateSkipIndicator(-skipDuration.inWholeMilliseconds)
|
||||
} else {
|
||||
val skipDuration = seekForward * multiplier
|
||||
player.seekForward(skipDuration)
|
||||
updateSkipIndicator(skipDuration.inWholeMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setHandledByRepeat(
|
||||
isBack: Boolean,
|
||||
handled: Boolean,
|
||||
) {
|
||||
if (isBack) {
|
||||
leftHandledByRepeat = handled
|
||||
} else {
|
||||
rightHandledByRepeat = handled
|
||||
}
|
||||
}
|
||||
|
||||
private fun handledByRepeat(isBack: Boolean): Boolean =
|
||||
if (isBack) {
|
||||
leftHandledByRepeat
|
||||
} else {
|
||||
rightHandledByRepeat
|
||||
}
|
||||
|
||||
private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
|
@ -35,6 +38,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.focusRestorer
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
|
|
@ -254,8 +258,34 @@ fun PlaybackOverlay(
|
|||
exit = slideOutVertically { it / 2 } + fadeOut(),
|
||||
) {
|
||||
if (chapters.isNotEmpty()) {
|
||||
val bringIntoViewRequester = remember { BringIntoViewRequester() }
|
||||
val chapterIndex =
|
||||
remember {
|
||||
val position = playerControls.currentPosition.milliseconds
|
||||
val index =
|
||||
chapters
|
||||
.indexOfFirst { it.position > position }
|
||||
.minus(1)
|
||||
.let {
|
||||
if (it < 0) {
|
||||
// Didn't find a chapter, so it's either the first or last
|
||||
if (position < chapters.first().position) {
|
||||
0
|
||||
} else {
|
||||
chapters.lastIndex
|
||||
}
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}.coerceIn(0, chapters.lastIndex)
|
||||
index
|
||||
}
|
||||
val listState = rememberLazyListState(chapterIndex)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.tryRequestFocus() }
|
||||
LaunchedEffect(Unit) {
|
||||
bringIntoViewRequester.bringIntoView()
|
||||
focusRequester.tryRequestFocus()
|
||||
}
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier =
|
||||
|
|
@ -276,6 +306,7 @@ fun PlaybackOverlay(
|
|||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
LazyRow(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier =
|
||||
|
|
@ -305,10 +336,19 @@ fun PlaybackOverlay(
|
|||
},
|
||||
interactionSource = interactionSource,
|
||||
modifier =
|
||||
Modifier.ifElse(
|
||||
index == 0,
|
||||
Modifier.focusRequester(focusRequester),
|
||||
),
|
||||
Modifier
|
||||
.ifElse(
|
||||
index == chapterIndex,
|
||||
Modifier
|
||||
.focusRequester(focusRequester)
|
||||
.bringIntoViewRequester(bringIntoViewRequester),
|
||||
).ifElse(
|
||||
index == 0,
|
||||
Modifier.focusProperties {
|
||||
// Prevent scrolling left on first card to prevent moving down
|
||||
left = FocusRequester.Cancel
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -448,8 +488,10 @@ fun PlaybackOverlay(
|
|||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.6f),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -570,29 +612,28 @@ fun Controller(
|
|||
fontSize = subtitleTextSize,
|
||||
)
|
||||
}
|
||||
if (showClock) {
|
||||
var endTimeStr by remember { mutableStateOf("...") }
|
||||
LaunchedEffect(playerControls) {
|
||||
while (isActive) {
|
||||
val remaining =
|
||||
(playerControls.duration - playerControls.currentPosition)
|
||||
.div(playerControls.playbackParameters.speed)
|
||||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = TimeFormatter.format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
|
||||
var endTimeStr by remember { mutableStateOf("...") }
|
||||
LaunchedEffect(playerControls) {
|
||||
while (isActive) {
|
||||
val remaining =
|
||||
(playerControls.duration - playerControls.currentPosition)
|
||||
.div(playerControls.playbackParameters.speed)
|
||||
.toLong()
|
||||
.milliseconds
|
||||
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
|
||||
endTimeStr = TimeFormatter.format(endTime)
|
||||
delay(1.seconds)
|
||||
}
|
||||
Text(
|
||||
text = "Ends $endTimeStr",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 32.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Ends $endTimeStr",
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier =
|
||||
Modifier
|
||||
.padding(end = 32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
// TODO need to move these up a level?
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ fun PlaybackPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
AmbientPlayerListener(player)
|
||||
var contentScale by remember(playerBackend) {
|
||||
mutableStateOf(
|
||||
if (playerBackend == PlayerBackend.MPV) {
|
||||
|
|
@ -236,6 +235,7 @@ fun PlaybackPageContent(
|
|||
skipWithLeftRight = true,
|
||||
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
|
||||
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
|
||||
getDurationMs = { player.duration.coerceAtLeast(0L) },
|
||||
controllerViewState = controllerViewState,
|
||||
updateSkipIndicator = updateSkipIndicator,
|
||||
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,
|
||||
|
|
@ -596,6 +596,9 @@ fun PlaybackPageContent(
|
|||
subtitleDelay = subtitleDelay,
|
||||
hasSubtitleDownloadPermission =
|
||||
remember(userDto) { userDto?.policy?.let { it.isAdministrator || it.enableSubtitleManagement } == true },
|
||||
// TODO Passing through audio prevents changing playback speed
|
||||
// See https://github.com/damontecres/Wholphin/issues/164
|
||||
playbackSpeedEnabled = playerBackend == PlayerBackend.MPV || currentPlayback?.audioDecoder != null,
|
||||
),
|
||||
onDismissRequest = {
|
||||
playbackDialog = null
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
|
|||
import com.github.damontecres.wholphin.services.PlaylistCreationResult
|
||||
import com.github.damontecres.wholphin.services.PlaylistCreator
|
||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
|
|
@ -140,6 +141,7 @@ class PlaybackViewModel
|
|||
val streamChoiceService: StreamChoiceService,
|
||||
private val userPreferencesService: UserPreferencesService,
|
||||
private val imageUrlService: ImageUrlService,
|
||||
private val screensaverService: ScreensaverService,
|
||||
@Assisted private val destination: Destination,
|
||||
) : ViewModel(),
|
||||
Player.Listener,
|
||||
|
|
@ -189,7 +191,10 @@ class PlaybackViewModel
|
|||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
addCloseable { disconnectPlayer() }
|
||||
addCloseable {
|
||||
screensaverService.keepScreenOn(false)
|
||||
disconnectPlayer()
|
||||
}
|
||||
init()
|
||||
}
|
||||
}
|
||||
|
|
@ -441,11 +446,20 @@ class PlaybackViewModel
|
|||
|
||||
// Create the correct player for the media
|
||||
createPlayer(videoStream?.hdr == true, videoStream?.is4k == true)
|
||||
|
||||
val subtitleLanguagePreference =
|
||||
serverRepository.currentUserDto.value
|
||||
?.configuration
|
||||
?.subtitleLanguagePreference
|
||||
val subtitleStreams =
|
||||
mediaSource.mediaStreams
|
||||
?.filter { it.type == MediaStreamType.SUBTITLE }
|
||||
?.map {
|
||||
.let {
|
||||
if (subtitleLanguagePreference.isNotNullOrBlank()) {
|
||||
it?.sortedByDescending { it.language != null && subtitleLanguagePreference == it.language }
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}?.map {
|
||||
SimpleMediaStream.from(context, it, true)
|
||||
}.orEmpty()
|
||||
|
||||
|
|
@ -1424,6 +1438,10 @@ class PlaybackViewModel
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
screensaverService.keepScreenOn(isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
data class PlayerState(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.github.damontecres.wholphin.ui.playback
|
||||
|
||||
internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8
|
||||
|
||||
/**
|
||||
* Shared seek acceleration profile for hold-to-seek behavior.
|
||||
* Keep this in sync anywhere directional key repeat seeking is handled.
|
||||
*/
|
||||
fun calculateSeekAccelerationMultiplier(
|
||||
repeatCount: Int,
|
||||
durationMs: Long,
|
||||
): Int {
|
||||
if (repeatCount <= 0 || durationMs <= 0L) return 1
|
||||
|
||||
// Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds.
|
||||
val scaledRepeatCount = repeatCount / 3
|
||||
if (scaledRepeatCount <= 0) return 1
|
||||
|
||||
val durationMinutes = durationMs / 60_000L
|
||||
return when {
|
||||
durationMinutes < 30 -> {
|
||||
if (scaledRepeatCount < 30) 1 else 2
|
||||
}
|
||||
|
||||
durationMinutes < 90 -> {
|
||||
when {
|
||||
scaledRepeatCount < 13 -> 1
|
||||
scaledRepeatCount < 50 -> 2
|
||||
scaledRepeatCount < 75 -> 3
|
||||
else -> 4
|
||||
}
|
||||
}
|
||||
|
||||
durationMinutes < 150 -> {
|
||||
when {
|
||||
scaledRepeatCount < 20 -> 1
|
||||
scaledRepeatCount < 40 -> 2
|
||||
scaledRepeatCount < 60 -> 4
|
||||
else -> 6
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
scaledRepeatCount < 20 -> 1
|
||||
scaledRepeatCount < 40 -> 3
|
||||
scaledRepeatCount < 60 -> 6
|
||||
else -> 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
|
@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
|
|||
enabled = enabled,
|
||||
progress = progressToUse,
|
||||
bufferedProgress = bufferedProgress,
|
||||
onLeft = {
|
||||
durationMs = durationMs,
|
||||
onLeft = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekProgress = (progressToUse - offset).coerceAtLeast(0f)
|
||||
seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
|
||||
hasSeeked = true
|
||||
seek(seekProgress)
|
||||
},
|
||||
onRight = {
|
||||
onRight = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekProgress = (progressToUse + offset).coerceAtMost(1f)
|
||||
seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
|
||||
hasSeeked = true
|
||||
seek(seekProgress)
|
||||
},
|
||||
|
|
@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
|
|||
enabled = enabled,
|
||||
progress = (progressToUse.toDouble() / durationMs).toFloat(),
|
||||
bufferedProgress = bufferedProgress,
|
||||
onLeft = {
|
||||
durationMs = durationMs,
|
||||
onLeft = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L)
|
||||
seekPositionMs =
|
||||
(progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
|
||||
hasSeeked = true
|
||||
onSeek(seekPositionMs)
|
||||
},
|
||||
onRight = {
|
||||
onRight = { multiplier ->
|
||||
controllerViewState.pulseControls()
|
||||
seekPositionMs =
|
||||
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs)
|
||||
(progressToUse + seekForward.inWholeMilliseconds * multiplier)
|
||||
.coerceAtMost(durationMs)
|
||||
hasSeeked = true
|
||||
onSeek(seekPositionMs)
|
||||
},
|
||||
|
|
@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
|
|||
fun SeekBarDisplay(
|
||||
progress: Float,
|
||||
bufferedProgress: Float,
|
||||
onLeft: () -> Unit,
|
||||
onRight: () -> Unit,
|
||||
durationMs: Long,
|
||||
onLeft: (Int) -> Unit,
|
||||
onRight: (Int) -> Unit,
|
||||
interactionSource: MutableInteractionSource,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
|
|
@ -158,14 +162,13 @@ fun SeekBarDisplay(
|
|||
val onSurface = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
var leftHandledByRepeat by remember { mutableStateOf(false) }
|
||||
var rightHandledByRepeat by remember { mutableStateOf(false) }
|
||||
val animatedIndicatorHeight by animateDpAsState(
|
||||
targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Canvas(
|
||||
modifier =
|
||||
Modifier
|
||||
|
|
@ -173,24 +176,71 @@ fun SeekBarDisplay(
|
|||
.height(animatedIndicatorHeight)
|
||||
.padding(horizontal = 4.dp)
|
||||
.onPreviewKeyEvent { event ->
|
||||
val trigger =
|
||||
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
|
||||
when (event.nativeKeyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> {
|
||||
if (trigger) onLeft.invoke()
|
||||
when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
leftHandledByRepeat = true
|
||||
onLeft.invoke(
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount,
|
||||
durationMs = durationMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
leftHandledByRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!leftHandledByRepeat) {
|
||||
onLeft.invoke(1)
|
||||
}
|
||||
leftHandledByRepeat = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> {
|
||||
if (trigger) onRight.invoke()
|
||||
when (event.type) {
|
||||
KeyEventType.KeyDown -> {
|
||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||
if (repeatCount > 0) {
|
||||
rightHandledByRepeat = true
|
||||
onRight.invoke(
|
||||
calculateSeekAccelerationMultiplier(
|
||||
repeatCount = repeatCount,
|
||||
durationMs = durationMs,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
rightHandledByRepeat = false
|
||||
}
|
||||
}
|
||||
|
||||
KeyEventType.KeyUp -> {
|
||||
if (!rightHandledByRepeat) {
|
||||
onRight.invoke(1)
|
||||
}
|
||||
rightHandledByRepeat = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
return@onPreviewKeyEvent false
|
||||
}
|
||||
}
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
}
|
||||
false
|
||||
}.handleDPadKeyEvents(
|
||||
onLeft = onLeft,
|
||||
onRight = onRight,
|
||||
).focusable(enabled = enabled, interactionSource = interactionSource),
|
||||
}.focusable(enabled = enabled, interactionSource = interactionSource),
|
||||
onDraw = {
|
||||
val yOffset = size.height.div(2)
|
||||
drawLine(
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
|
|||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.util.SortedSet
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
@Composable
|
||||
|
|
@ -226,7 +225,7 @@ fun <T> ComposablePreference(
|
|||
}
|
||||
|
||||
is AppMultiChoicePreference<*, *> -> {
|
||||
val values = stringArrayResource(preference.displayValues).toSortedSet()
|
||||
val values = stringArrayResource(preference.displayValues)
|
||||
val summary =
|
||||
preference.summary?.let { stringResource(it) }
|
||||
?: preference.summary(context, value)
|
||||
|
|
@ -237,12 +236,15 @@ fun <T> ComposablePreference(
|
|||
list
|
||||
}
|
||||
MultiChoicePreference(
|
||||
possibleValues = values as SortedSet<Any>,
|
||||
possibleValues = preference.allValues,
|
||||
selectedValues = selectedValues,
|
||||
title = title,
|
||||
summary = summary,
|
||||
onValueChange = {
|
||||
onValueChange.invoke(selectedValues.toList() as T)
|
||||
onValueChange.invoke(it.toList() as T)
|
||||
},
|
||||
valueDisplay = { index, _ ->
|
||||
Text(values[index])
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue