mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
Merge branch 'main' into fea/music
This commit is contained in:
commit
59a0bee0a7
51 changed files with 1745 additions and 274 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -74,7 +74,7 @@ jobs:
|
||||||
echo "SHA256 checksums:"
|
echo "SHA256 checksums:"
|
||||||
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
|
||||||
- name: Upload AAB
|
- name: Upload AAB
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: AAB
|
name: AAB
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ android {
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = gitTags.trim().lines().size
|
versionCode = gitTags.trim().lines().size
|
||||||
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
@ -140,6 +140,12 @@ android {
|
||||||
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protobuf {
|
protobuf {
|
||||||
|
|
@ -254,6 +260,7 @@ dependencies {
|
||||||
implementation(libs.androidx.room.testing)
|
implementation(libs.androidx.room.testing)
|
||||||
implementation(libs.androidx.palette.ktx)
|
implementation(libs.androidx.palette.ktx)
|
||||||
implementation(libs.androidx.media3.effect)
|
implementation(libs.androidx.media3.effect)
|
||||||
|
implementation(libs.androidx.runner)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
ksp(libs.hilt.android.compiler)
|
ksp(libs.hilt.android.compiler)
|
||||||
ksp(libs.androidx.hilt.compiler)
|
ksp(libs.androidx.hilt.compiler)
|
||||||
|
|
@ -292,4 +299,9 @@ dependencies {
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.hilt.android.testing)
|
||||||
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
androidTestImplementation(libs.mockk.android)
|
||||||
|
androidTestImplementation(libs.hilt.android.testing)
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -8,12 +8,9 @@ import android.view.WindowManager
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
|
||||||
import androidx.navigation3.runtime.NavEntry
|
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
|
||||||
import androidx.navigation3.ui.NavDisplay
|
|
||||||
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
import androidx.tv.material3.ExperimentalTvMaterial3Api
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreference
|
import com.github.damontecres.wholphin.preferences.AppPreference
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
|
||||||
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
import com.github.damontecres.wholphin.services.AppUpgradeHandler
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
|
||||||
|
|
@ -62,17 +47,12 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService
|
||||||
import com.github.damontecres.wholphin.ui.CoilConfig
|
import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaver
|
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
|
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
|
||||||
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
|
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
|
||||||
import com.github.damontecres.wholphin.util.DebugLogTree
|
import com.github.damontecres.wholphin.util.DebugLogTree
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
@ -87,7 +68,6 @@ import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.serializer.toUUID
|
|
||||||
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -231,134 +211,19 @@ class MainActivity : AppCompatActivity() {
|
||||||
true,
|
true,
|
||||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||||
) {
|
) {
|
||||||
Surface(
|
val requestedDestination =
|
||||||
modifier =
|
remember(intent) {
|
||||||
Modifier
|
intent?.let(::extractDestination) ?: Destination.Home()
|
||||||
.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 -> {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
backdropService.clearBackdrop()
|
|
||||||
}
|
|
||||||
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(),
|
|
||||||
// TODO undo
|
|
||||||
?: Destination.MediaItem(
|
|
||||||
itemId = "011ef0c7ca45684f2cd9dd3b020ca5f6".toUUID(),
|
|
||||||
type = BaseItemKind.MUSIC_ALBUM,
|
|
||||||
),
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
MainContent(
|
||||||
|
backStack = setupNavigationManager.backStack,
|
||||||
|
navigationManager = navigationManager,
|
||||||
|
appPreferences = appPreferences,
|
||||||
|
backdropService = backdropService,
|
||||||
|
screensaverService = screensaverService,
|
||||||
|
requestedDestination = requestedDestination,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +271,22 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
super.onStart()
|
super.onStart()
|
||||||
Timber.d("onStart")
|
Timber.d("onStart")
|
||||||
|
|
||||||
|
lifecycleScope.launchDefault {
|
||||||
|
val appPreferences = userPreferencesDataStore.data.first()
|
||||||
|
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
|
||||||
|
try {
|
||||||
|
updateChecker.maybeShowUpdateToast(
|
||||||
|
appPreferences.updateUrl,
|
||||||
|
)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(
|
||||||
|
ex,
|
||||||
|
"Exception during update check",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
|
|
||||||
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
149
app/src/main/java/com/github/damontecres/wholphin/MainContent.kt
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
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
|
||||||
|
ProvideLocalClock {
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -62,13 +62,13 @@ class WholphinApplication :
|
||||||
.penaltyDeathOnNetwork()
|
.penaltyDeathOnNetwork()
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
StrictMode.setVmPolicy(
|
// StrictMode.setVmPolicy(
|
||||||
StrictMode.VmPolicy
|
// StrictMode.VmPolicy
|
||||||
.Builder()
|
// .Builder()
|
||||||
.detectAll()
|
// .detectAll()
|
||||||
.penaltyLog()
|
// .penaltyLog()
|
||||||
.build(),
|
// .build(),
|
||||||
)
|
// )
|
||||||
}
|
}
|
||||||
OkHttp.initialize(this)
|
OkHttp.initialize(this)
|
||||||
initAcra {
|
initAcra {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
@ -69,16 +70,18 @@ class WholphinDreamService :
|
||||||
}
|
}
|
||||||
prefs?.let { prefs ->
|
prefs?.let { prefs ->
|
||||||
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
|
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
|
||||||
val screensaverPrefs =
|
ProvideLocalClock {
|
||||||
prefs.appPreferences.interfacePreferences.screensaverPreference
|
val screensaverPrefs =
|
||||||
val currentItem by itemFlow.collectAsState(null)
|
prefs.appPreferences.interfacePreferences.screensaverPreference
|
||||||
AppScreensaverContent(
|
val currentItem by itemFlow.collectAsState(null)
|
||||||
currentItem = currentItem,
|
AppScreensaverContent(
|
||||||
showClock = screensaverPrefs.showClock,
|
currentItem = currentItem,
|
||||||
duration = screensaverPrefs.duration.milliseconds,
|
showClock = screensaverPrefs.showClock,
|
||||||
animate = screensaverPrefs.animate,
|
duration = screensaverPrefs.duration.milliseconds,
|
||||||
modifier = Modifier.fillMaxSize(),
|
animate = screensaverPrefs.animate,
|
||||||
)
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ import androidx.lifecycle.map
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.toServerString
|
import com.github.damontecres.wholphin.ui.toServerString
|
||||||
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -41,6 +43,7 @@ class ServerRepository
|
||||||
val serverDao: JellyfinServerDao,
|
val serverDao: JellyfinServerDao,
|
||||||
val apiClient: ApiClient,
|
val apiClient: ApiClient,
|
||||||
val userPreferencesDataStore: DataStore<AppPreferences>,
|
val userPreferencesDataStore: DataStore<AppPreferences>,
|
||||||
|
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
) {
|
) {
|
||||||
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
|
||||||
val current: LiveData<CurrentUser?> = _current
|
val current: LiveData<CurrentUser?> = _current
|
||||||
|
|
@ -57,7 +60,7 @@ class ServerRepository
|
||||||
* The current user is removed
|
* The current user is removed
|
||||||
*/
|
*/
|
||||||
suspend fun addAndChangeServer(server: JellyfinServer) {
|
suspend fun addAndChangeServer(server: JellyfinServer) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
serverDao.addOrUpdateServer(server)
|
serverDao.addOrUpdateServer(server)
|
||||||
}
|
}
|
||||||
apiClient.update(baseUrl = server.url, accessToken = null)
|
apiClient.update(baseUrl = server.url, accessToken = null)
|
||||||
|
|
@ -71,7 +74,7 @@ class ServerRepository
|
||||||
server: JellyfinServer,
|
server: JellyfinServer,
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
): CurrentUser? =
|
): CurrentUser? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
if (server.id != user.serverId) {
|
if (server.id != user.serverId) {
|
||||||
throw IllegalStateException("User is not part of the server")
|
throw IllegalStateException("User is not part of the server")
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +129,7 @@ class ServerRepository
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val serverAndUsers =
|
val serverAndUsers =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
serverDao.getServer(serverId)
|
serverDao.getServer(serverId)
|
||||||
}
|
}
|
||||||
if (serverAndUsers != null) {
|
if (serverAndUsers != null) {
|
||||||
|
|
@ -149,7 +152,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
serverId?.let { serverDao.getServer(serverId)?.server }
|
serverId?.let { serverDao.getServer(serverId)?.server }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,7 +166,7 @@ class ServerRepository
|
||||||
suspend fun changeUser(
|
suspend fun changeUser(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
authenticationResult: AuthenticationResult,
|
authenticationResult: AuthenticationResult,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(ioDispatcher) {
|
||||||
val accessToken = authenticationResult.accessToken
|
val accessToken = authenticationResult.accessToken
|
||||||
if (accessToken != null) {
|
if (accessToken != null) {
|
||||||
val authedUser = authenticationResult.user
|
val authedUser = authenticationResult.user
|
||||||
|
|
@ -213,7 +216,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
apiClient.update(accessToken = null)
|
apiClient.update(accessToken = null)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
serverDao.deleteUser(user.serverId, user.id)
|
serverDao.deleteUser(user.serverId, user.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,7 +236,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
apiClient.update(baseUrl = null, accessToken = null)
|
apiClient.update(baseUrl = null, accessToken = null)
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
serverDao.deleteServer(server.id)
|
serverDao.deleteServer(server.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +255,7 @@ class ServerRepository
|
||||||
suspend fun setUserPin(
|
suspend fun setUserPin(
|
||||||
user: JellyfinUser,
|
user: JellyfinUser,
|
||||||
pin: String?,
|
pin: String?,
|
||||||
) = withContext(Dispatchers.IO) {
|
) = withContext(ioDispatcher) {
|
||||||
val newUser = user.copy(pin = pin)
|
val newUser = user.copy(pin = pin)
|
||||||
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
val updatedUser = serverDao.addOrUpdateUser(newUser)
|
||||||
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
|
||||||
|
|
@ -265,7 +268,7 @@ class ServerRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authorizeQuickConnect(code: String): Boolean =
|
suspend fun authorizeQuickConnect(code: String): Boolean =
|
||||||
withContext(Dispatchers.IO) {
|
withContext(ioDispatcher) {
|
||||||
val userId = currentUser.value?.id
|
val userId = currentUser.value?.id
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
Timber.e("No user logged in for Quick Connect authorization")
|
Timber.e("No user logged in for Quick Connect authorization")
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,8 @@ data class BaseItem(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val canDelete: Boolean get() = data.canDelete == true
|
||||||
|
|
||||||
@Transient
|
@Transient
|
||||||
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
|
||||||
valueToIndex = { it.number },
|
valueToIndex = { it.number },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
val ManageMedia =
|
||||||
|
AppSwitchPreference<AppPreferences>(
|
||||||
|
title = R.string.show_media_management,
|
||||||
|
defaultValue = false,
|
||||||
|
getter = { it.interfacePreferences.enableMediaManagement },
|
||||||
|
setter = { prefs, value ->
|
||||||
|
prefs.updateInterfacePreferences { enableMediaManagement = value }
|
||||||
|
},
|
||||||
|
summaryOn = R.string.enabled,
|
||||||
|
summaryOff = R.string.disabled,
|
||||||
|
)
|
||||||
|
|
||||||
val OneClickPause =
|
val OneClickPause =
|
||||||
AppSwitchPreference<AppPreferences>(
|
AppSwitchPreference<AppPreferences>(
|
||||||
title = R.string.one_click_pause,
|
title = R.string.one_click_pause,
|
||||||
|
|
@ -1110,6 +1122,7 @@ val advancedPreferences =
|
||||||
preferences =
|
preferences =
|
||||||
listOf(
|
listOf(
|
||||||
AppPreference.ShowClock,
|
AppPreference.ShowClock,
|
||||||
|
AppPreference.ManageMedia,
|
||||||
AppPreference.CombineContinueNext,
|
AppPreference.CombineContinueNext,
|
||||||
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
|
||||||
// AppPreference.NavDrawerSwitchOnFocus,
|
// AppPreference.NavDrawerSwitchOnFocus,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
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.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 {
|
||||||
|
Timber.v("canDelete %s: %s", item.id, item.canDelete)
|
||||||
|
val enabled =
|
||||||
|
userPreferencesService
|
||||||
|
.getCurrent()
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
import com.github.damontecres.wholphin.util.supportedCollectionTypes
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.jellyfin.sdk.api.client.ApiClient
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
import org.jellyfin.sdk.api.client.extensions.liveTvApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
import org.jellyfin.sdk.api.client.extensions.userViewsApi
|
||||||
|
|
@ -176,7 +178,9 @@ class NavDrawerService
|
||||||
val allItems = builtins + libraries
|
val allItems = builtins + libraries
|
||||||
|
|
||||||
val navDrawerPins =
|
val navDrawerPins =
|
||||||
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
withContext(Dispatchers.IO) {
|
||||||
|
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
|
||||||
|
}
|
||||||
|
|
||||||
val items = mutableListOf<NavDrawerItem>()
|
val items = mutableListOf<NavDrawerItem>()
|
||||||
val moreItems = mutableListOf<NavDrawerItem>()
|
val moreItems = mutableListOf<NavDrawerItem>()
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import javax.inject.Singleton
|
||||||
class SetupNavigationManager
|
class SetupNavigationManager
|
||||||
@Inject
|
@Inject
|
||||||
constructor() {
|
constructor() {
|
||||||
var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading)
|
var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the specified [SetupDestination]
|
* Go to the specified [SetupDestination]
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import androidx.work.Constraints
|
||||||
import androidx.work.ExistingPeriodicWorkPolicy
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
import androidx.work.NetworkType
|
import androidx.work.NetworkType
|
||||||
import androidx.work.PeriodicWorkRequestBuilder
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.workDataOf
|
import androidx.work.workDataOf
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
|
@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.hours
|
import kotlin.time.Duration.Companion.hours
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
@ -31,7 +34,6 @@ class SuggestionsSchedulerService
|
||||||
constructor(
|
constructor(
|
||||||
@param:ActivityContext private val context: Context,
|
@param:ActivityContext private val context: Context,
|
||||||
private val serverRepository: ServerRepository,
|
private val serverRepository: ServerRepository,
|
||||||
private val cache: SuggestionsCache,
|
|
||||||
private val workManager: WorkManager,
|
private val workManager: WorkManager,
|
||||||
) {
|
) {
|
||||||
private val activity =
|
private val activity =
|
||||||
|
|
@ -42,6 +44,7 @@ class SuggestionsSchedulerService
|
||||||
|
|
||||||
// Exposed for testing
|
// Exposed for testing
|
||||||
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
serverRepository.current.observe(activity) { user ->
|
serverRepository.current.observe(activity) { user ->
|
||||||
|
|
@ -60,6 +63,28 @@ class SuggestionsSchedulerService
|
||||||
userId: UUID,
|
userId: UUID,
|
||||||
serverId: UUID,
|
serverId: UUID,
|
||||||
) {
|
) {
|
||||||
|
val workInfos =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
workManager
|
||||||
|
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
val activeWork =
|
||||||
|
workInfos.firstOrNull {
|
||||||
|
!it.state.isFinished
|
||||||
|
}
|
||||||
|
val scheduledUserId =
|
||||||
|
activeWork
|
||||||
|
?.tags
|
||||||
|
?.firstOrNull { it.startsWith("user:") }
|
||||||
|
?.removePrefix("user:")
|
||||||
|
|
||||||
|
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
|
||||||
|
if (isAlreadyScheduledForUser) {
|
||||||
|
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val constraints =
|
val constraints =
|
||||||
Constraints
|
Constraints
|
||||||
.Builder()
|
.Builder()
|
||||||
|
|
@ -75,12 +100,18 @@ class SuggestionsSchedulerService
|
||||||
val periodicWorkRequestBuilder =
|
val periodicWorkRequestBuilder =
|
||||||
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
PeriodicWorkRequestBuilder<SuggestionsWorker>(
|
||||||
repeatInterval = 12.hours.toJavaDuration(),
|
repeatInterval = 12.hours.toJavaDuration(),
|
||||||
|
flexTimeInterval = 1.hours.toJavaDuration(),
|
||||||
).setConstraints(constraints)
|
).setConstraints(constraints)
|
||||||
.setBackoffCriteria(
|
.setBackoffCriteria(
|
||||||
BackoffPolicy.EXPONENTIAL,
|
BackoffPolicy.EXPONENTIAL,
|
||||||
15.minutes.toJavaDuration(),
|
15.minutes.toJavaDuration(),
|
||||||
).setInputData(inputData)
|
).setInputData(inputData)
|
||||||
.setInitialDelay(60.seconds.toJavaDuration())
|
.addTag("user:$userId")
|
||||||
|
|
||||||
|
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
|
||||||
|
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
|
||||||
|
|
||||||
|
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
uniqueWorkName = SuggestionsWorker.WORK_NAME,
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,17 @@ class SuggestionsWorker
|
||||||
itemsPerRow,
|
itemsPerRow,
|
||||||
)
|
)
|
||||||
ensureActive()
|
ensureActive()
|
||||||
|
val newIds = suggestions.map { it.id }
|
||||||
|
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
|
||||||
|
if (cachedIds == newIds) {
|
||||||
|
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
|
||||||
|
return@runCatching
|
||||||
|
}
|
||||||
cache.put(
|
cache.put(
|
||||||
userId,
|
userId,
|
||||||
view.id,
|
view.id,
|
||||||
itemKind,
|
itemKind,
|
||||||
suggestions.map { it.id },
|
newIds,
|
||||||
)
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
Timber.e(
|
Timber.e(
|
||||||
|
|
@ -242,7 +248,7 @@ class SuggestionsWorker
|
||||||
GetItemsRequest(
|
GetItemsRequest(
|
||||||
parentId = parentId,
|
parentId = parentId,
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
|
fields = extraFields,
|
||||||
includeItemTypes = listOf(itemKind),
|
includeItemTypes = listOf(itemKind),
|
||||||
genreIds = genreIds,
|
genreIds = genreIds,
|
||||||
recursive = true,
|
recursive = true,
|
||||||
|
|
@ -252,7 +258,7 @@ class SuggestionsWorker
|
||||||
sortOrder = sortOrder?.let { listOf(it) },
|
sortOrder = sortOrder?.let { listOf(it) },
|
||||||
limit = limit,
|
limit = limit,
|
||||||
enableTotalRecordCount = false,
|
enableTotalRecordCount = false,
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 0,
|
||||||
)
|
)
|
||||||
return GetItemsRequestHandler
|
return GetItemsRequestHandler
|
||||||
.execute(api, request)
|
.execute(api, request)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
|
@ -49,6 +50,14 @@ annotation class IoCoroutineScope
|
||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class DefaultCoroutineScope
|
annotation class DefaultCoroutineScope
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class IoDispatcher
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class DefaultDispatcher
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object AppModule {
|
object AppModule {
|
||||||
|
|
@ -62,12 +71,6 @@ object AppModule {
|
||||||
version = BuildConfig.VERSION_NAME,
|
version = BuildConfig.VERSION_NAME,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun deviceInfo(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): DeviceInfo = androidDevice(context)
|
|
||||||
|
|
||||||
@StandardOkHttpClient
|
@StandardOkHttpClient
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
@ -177,15 +180,29 @@ object AppModule {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@IoDispatcher
|
||||||
|
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@IoCoroutineScope
|
@IoCoroutineScope
|
||||||
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
fun ioCoroutineScope(
|
||||||
|
@IoDispatcher dispatcher: CoroutineDispatcher,
|
||||||
|
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@DefaultDispatcher
|
||||||
|
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@DefaultCoroutineScope
|
@DefaultCoroutineScope
|
||||||
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
fun defaultCoroutineScope(
|
||||||
|
@DefaultDispatcher dispatcher: CoroutineDispatcher,
|
||||||
|
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
@ -199,3 +216,13 @@ object AppModule {
|
||||||
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
) = SeerrApi(okHttpClient)
|
) = SeerrApi(okHttpClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DeviceModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun deviceInfo(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DeviceInfo = androidDevice(context)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ val DefaultItemFields =
|
||||||
ItemFields.MEDIA_SOURCES,
|
ItemFields.MEDIA_SOURCES,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
ItemFields.PARENT_ID,
|
ItemFields.PARENT_ID,
|
||||||
|
ItemFields.CAN_DELETE,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -72,6 +73,7 @@ val SlimItemFields =
|
||||||
ItemFields.SORT_NAME,
|
ItemFields.SORT_NAME,
|
||||||
ItemFields.MEDIA_SOURCE_COUNT,
|
ItemFields.MEDIA_SOURCE_COUNT,
|
||||||
ItemFields.PARENT_ID,
|
ItemFields.PARENT_ID,
|
||||||
|
ItemFields.CAN_DELETE,
|
||||||
)
|
)
|
||||||
|
|
||||||
val PhotoItemFields =
|
val PhotoItemFields =
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,7 @@ fun AppScreensaverContent(
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = currentItem?.title ?: "",
|
text = currentItem?.title ?: "",
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
|
|
@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
|
|
@ -128,6 +130,7 @@ class CollectionFolderViewModel
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
private val themeSongPlayer: ThemeSongPlayer,
|
private val themeSongPlayer: ThemeSongPlayer,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
|
private val mediaManagementService: MediaManagementService,
|
||||||
val mediaReportService: MediaReportService,
|
val mediaReportService: MediaReportService,
|
||||||
@Assisted itemId: String,
|
@Assisted itemId: String,
|
||||||
@Assisted initialSortAndDirection: SortAndDirection?,
|
@Assisted initialSortAndDirection: SortAndDirection?,
|
||||||
|
|
@ -199,6 +202,19 @@ class CollectionFolderViewModel
|
||||||
loading.setValueOnMain(DataLoadingState.Error(ex))
|
loading.setValueOnMain(DataLoadingState.Error(ex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
mediaManagementService.deletedItemFlow.collect { deletedItem ->
|
||||||
|
val pager =
|
||||||
|
((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>)
|
||||||
|
position.let {
|
||||||
|
Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id)
|
||||||
|
val item = pager?.get(it)
|
||||||
|
if (item?.id == deletedItem.item.id) {
|
||||||
|
pager.refreshPagesAfter(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveLibraryDisplayInfo(
|
private fun saveLibraryDisplayInfo(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import androidx.annotation.StringRes
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
import androidx.compose.foundation.gestures.scrollBy
|
import androidx.compose.foundation.gestures.scrollBy
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
|
@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
|
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
|
@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.TrackIndex
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
import com.github.damontecres.wholphin.ui.FontAwesome
|
import com.github.damontecres.wholphin.ui.FontAwesome
|
||||||
|
import com.github.damontecres.wholphin.ui.ifElse
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isDown
|
||||||
|
import com.github.damontecres.wholphin.ui.playback.isUp
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.ExceptionHandler
|
import com.github.damontecres.wholphin.util.ExceptionHandler
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
@ -259,31 +271,65 @@ fun DialogPopupContent(
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
items(dialogItems) {
|
itemsIndexed(dialogItems) { index, item ->
|
||||||
when (it) {
|
when (item) {
|
||||||
is DialogItemDivider -> {
|
is DialogItemDivider -> {
|
||||||
HorizontalDivider(Modifier.height(16.dp))
|
HorizontalDivider(Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
is DialogItem -> {
|
is DialogItem -> {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
ListItem(
|
ListItem(
|
||||||
selected = it.selected,
|
selected = item.selected,
|
||||||
enabled = !waiting && it.enabled,
|
enabled = !waiting && item.enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (dismissOnClick) {
|
if (dismissOnClick) {
|
||||||
onDismissRequest.invoke()
|
onDismissRequest.invoke()
|
||||||
}
|
}
|
||||||
it.onClick.invoke()
|
item.onClick.invoke()
|
||||||
},
|
},
|
||||||
headlineContent = it.headlineContent,
|
headlineContent = item.headlineContent,
|
||||||
overlineContent = it.overlineContent,
|
overlineContent = item.overlineContent,
|
||||||
supportingContent = it.supportingContent,
|
supportingContent = item.supportingContent,
|
||||||
leadingContent = it.leadingContent,
|
leadingContent = item.leadingContent,
|
||||||
trailingContent = it.trailingContent,
|
trailingContent = item.trailingContent,
|
||||||
modifier = Modifier,
|
interactionSource = interactionSource,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.focusRequester(focusRequesters[index])
|
||||||
|
.ifElse(
|
||||||
|
index == 0,
|
||||||
|
Modifier.onKeyEvent {
|
||||||
|
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(dialogItems.lastIndex)
|
||||||
|
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
|
||||||
|
}
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
},
|
||||||
|
).ifElse(
|
||||||
|
index == dialogItems.lastIndex,
|
||||||
|
Modifier.onKeyEvent {
|
||||||
|
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
|
||||||
|
scope.launch {
|
||||||
|
listState.animateScrollToItem(0)
|
||||||
|
focusRequesters[0].tryRequestFocus()
|
||||||
|
}
|
||||||
|
return@onKeyEvent true
|
||||||
|
}
|
||||||
|
false
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -471,6 +517,80 @@ fun ConfirmDialogContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ConfirmDeleteDialog(
|
||||||
|
itemTitle: String,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
) {
|
||||||
|
BasicDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
modifier = Modifier.wrapContentWidth(),
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.delete_item),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = itemTitle,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||||
|
modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onCancel,
|
||||||
|
modifier = Modifier.width(72.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.cancel),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onConfirm,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Red.copy(alpha = .8f),
|
||||||
|
modifier = Modifier,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.confirm),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun chooseVersionParams(
|
fun chooseVersionParams(
|
||||||
context: Context,
|
context: Context,
|
||||||
sources: List<MediaSourceInfo>,
|
sources: List<MediaSourceInfo>,
|
||||||
|
|
@ -536,6 +656,9 @@ fun chooseStream(
|
||||||
)
|
)
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
|
leadingContent = {
|
||||||
|
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
|
||||||
|
},
|
||||||
headlineContent = {
|
headlineContent = {
|
||||||
Text(text = stringResource(R.string.only_forced_subtitles))
|
Text(text = stringResource(R.string.only_forced_subtitles))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.focusGroup
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
|
@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
|
@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.painter.Painter
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
|
@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
|
import androidx.tv.material3.LocalContentColor
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
|
|
@ -234,7 +241,7 @@ fun ExpandablePlayButton(
|
||||||
fun ExpandablePlayButton(
|
fun ExpandablePlayButton(
|
||||||
@StringRes title: Int,
|
@StringRes title: Int,
|
||||||
resume: Duration,
|
resume: Duration,
|
||||||
icon: @Composable () -> Unit,
|
icon: @Composable BoxScope.() -> Unit,
|
||||||
onClick: (position: Duration) -> Unit,
|
onClick: (position: Duration) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
|
@ -254,12 +261,13 @@ fun ExpandablePlayButton(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 2.dp, top = 2.dp)
|
.padding(start = 2.dp)
|
||||||
.height(MinButtonSize),
|
.height(MinButtonSize),
|
||||||
) {
|
) {
|
||||||
icon.invoke()
|
icon.invoke(this)
|
||||||
}
|
}
|
||||||
AnimatedVisibility(isFocused) {
|
AnimatedVisibility(isFocused) {
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
@ -359,6 +367,48 @@ fun TrailerButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val focused by interactionSource.collectIsFocusedAsState()
|
||||||
|
val iconTint by
|
||||||
|
animateColorAsState(
|
||||||
|
targetValue =
|
||||||
|
if (focused) {
|
||||||
|
Color.Red.copy(alpha = .8f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurface.copy(
|
||||||
|
alpha = 0.8f,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
animationSpec =
|
||||||
|
tween(
|
||||||
|
easing = LinearEasing,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ExpandablePlayButton(
|
||||||
|
title = R.string.delete,
|
||||||
|
resume = Duration.ZERO,
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(start = 2.dp)
|
||||||
|
.size(24.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = { onClick.invoke() },
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@PreviewTvSpec
|
@PreviewTvSpec
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExpandablePlayButtonsPreview() {
|
private fun ExpandablePlayButtonsPreview() {
|
||||||
|
|
@ -417,12 +467,19 @@ private fun ViewOptionsPreview() {
|
||||||
onClick = {},
|
onClick = {},
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
)
|
)
|
||||||
|
DeleteButton(
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
SortByButton(
|
SortByButton(
|
||||||
sortOptions = listOf(),
|
sortOptions = listOf(),
|
||||||
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
|
||||||
onSortChange = {},
|
onSortChange = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
DeleteButton(
|
||||||
|
onClick = {},
|
||||||
|
interactionSource = source,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ data class MoreDialogActions(
|
||||||
val onClickFavorite: (UUID, Boolean) -> Unit,
|
val onClickFavorite: (UUID, Boolean) -> Unit,
|
||||||
val onClickAddPlaylist: (UUID) -> Unit,
|
val onClickAddPlaylist: (UUID) -> Unit,
|
||||||
val onSendMediaInfo: (UUID) -> Unit,
|
val onSendMediaInfo: (UUID) -> Unit,
|
||||||
|
val onClickDelete: (BaseItem) -> Unit = {},
|
||||||
)
|
)
|
||||||
|
|
||||||
enum class ClearChosenStreams {
|
enum class ClearChosenStreams {
|
||||||
|
|
@ -62,6 +63,7 @@ fun buildMoreDialogItems(
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
canClearChosenStreams: Boolean,
|
canClearChosenStreams: Boolean,
|
||||||
|
canDelete: Boolean = false,
|
||||||
actions: MoreDialogActions,
|
actions: MoreDialogActions,
|
||||||
onChooseVersion: () -> Unit,
|
onChooseVersion: () -> Unit,
|
||||||
onChooseTracks: (MediaStreamType) -> Unit,
|
onChooseTracks: (MediaStreamType) -> Unit,
|
||||||
|
|
@ -139,6 +141,17 @@ fun buildMoreDialogItems(
|
||||||
actions.onClickAddPlaylist.invoke(item.id)
|
actions.onClickAddPlaylist.invoke(item.id)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (canDelete) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
actions.onClickDelete.invoke(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
|
@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome(
|
||||||
playbackPosition: Duration,
|
playbackPosition: Duration,
|
||||||
watched: Boolean,
|
watched: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
canDelete: Boolean = false,
|
||||||
actions: MoreDialogActions,
|
actions: MoreDialogActions,
|
||||||
): List<DialogItem> =
|
): List<DialogItem> =
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome(
|
||||||
actions.onClickAddPlaylist.invoke(itemId)
|
actions.onClickAddPlaylist.invoke(itemId)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (canDelete) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
actions.onClickDelete.invoke(item)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
add(
|
add(
|
||||||
DialogItem(
|
DialogItem(
|
||||||
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -111,6 +112,7 @@ fun MovieDetails(
|
||||||
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
|
|
||||||
val moreActions =
|
val moreActions =
|
||||||
MoreDialogActions(
|
MoreDialogActions(
|
||||||
|
|
@ -126,6 +128,7 @@ fun MovieDetails(
|
||||||
showPlaylistDialog.makePresent(itemId)
|
showPlaylistDialog.makePresent(itemId)
|
||||||
},
|
},
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onClickDelete = { showDeleteDialog = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
|
|
@ -201,6 +204,7 @@ fun MovieDetails(
|
||||||
seriesId = null,
|
seriesId = null,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||||
|
canDelete = viewModel.canDelete,
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -291,6 +295,7 @@ fun MovieDetails(
|
||||||
playbackPosition = similar.playbackPosition,
|
playbackPosition = similar.playbackPosition,
|
||||||
watched = similar.played,
|
watched = similar.played,
|
||||||
favorite = similar.favorite,
|
favorite = similar.favorite,
|
||||||
|
canDelete = false,
|
||||||
actions = moreActions,
|
actions = moreActions,
|
||||||
)
|
)
|
||||||
moreDialog =
|
moreDialog =
|
||||||
|
|
@ -362,6 +367,16 @@ fun MovieDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showDeleteDialog?.let { item ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.title ?: "",
|
||||||
|
onCancel = { showDeleteDialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.deleteItem(item)
|
||||||
|
showDeleteDialog = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
|
@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
|
|
@ -73,6 +75,7 @@ class MovieViewModel
|
||||||
private val extrasService: ExtrasService,
|
private val extrasService: ExtrasService,
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
|
private val mediaManagementService: MediaManagementService,
|
||||||
@Assisted val itemId: UUID,
|
@Assisted val itemId: UUID,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@AssistedFactory
|
@AssistedFactory
|
||||||
|
|
@ -90,6 +93,9 @@ class MovieViewModel
|
||||||
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
||||||
|
var canDelete: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
init {
|
init {
|
||||||
init()
|
init()
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +112,7 @@ class MovieViewModel
|
||||||
api.userLibraryApi.getItem(itemId).content.let {
|
api.userLibraryApi.getItem(itemId).content.let {
|
||||||
BaseItem.from(it, api)
|
BaseItem.from(it, api)
|
||||||
}
|
}
|
||||||
|
canDelete = mediaManagementService.canDelete(item)
|
||||||
this@MovieViewModel.item.setValueOnMain(item)
|
this@MovieViewModel.item.setValueOnMain(item)
|
||||||
item
|
item
|
||||||
}
|
}
|
||||||
|
|
@ -274,4 +281,10 @@ class MovieViewModel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteItem(item: BaseItem) {
|
||||||
|
deleteItem(context, mediaManagementService, item) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
|
@ -91,10 +92,25 @@ fun SearchForContent(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val titleRes =
|
||||||
|
remember {
|
||||||
|
when (searchType) {
|
||||||
|
BaseItemKind.BOX_SET -> R.string.collections
|
||||||
|
BaseItemKind.PLAYLIST -> R.string.playlists
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val title = titleRes?.let { stringResource(it) } ?: ""
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.search_for, title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -190,16 +206,8 @@ fun SearchForContent(
|
||||||
text = stringResource(R.string.no_results),
|
text = stringResource(R.string.no_results),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val titleRes =
|
|
||||||
remember {
|
|
||||||
when (searchType) {
|
|
||||||
BaseItemKind.BOX_SET -> R.string.collections
|
|
||||||
BaseItemKind.PLAYLIST -> R.string.playlists
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemRow(
|
ItemRow(
|
||||||
title = titleRes?.let { stringResource(it) } ?: "",
|
title = "",
|
||||||
items = st.items,
|
items = st.items,
|
||||||
onClickItem = { _, item -> onClick.invoke(item) },
|
onClickItem = { _, item -> onClick.invoke(item) },
|
||||||
onLongClickItem = { _, _ -> },
|
onLongClickItem = { _, _ -> },
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
import androidx.compose.foundation.relocation.BringIntoViewRequester
|
||||||
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
import androidx.compose.foundation.relocation.bringIntoViewRequester
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
|
@ -24,12 +25,14 @@ import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusDirection
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
@ -53,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
import com.github.damontecres.wholphin.ui.cards.ItemRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
import com.github.damontecres.wholphin.ui.cards.PersonRow
|
||||||
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
import com.github.damontecres.wholphin.ui.cards.SeasonCard
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
|
||||||
|
import com.github.damontecres.wholphin.ui.components.DeleteButton
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogItem
|
import com.github.damontecres.wholphin.ui.components.DialogItem
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
|
|
@ -76,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
|
||||||
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
|
|
@ -102,21 +108,25 @@ fun SeriesDetails(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
|
||||||
|
|
||||||
val item by viewModel.item.observeAsState()
|
val item by viewModel.item.observeAsState()
|
||||||
|
val canDelete by viewModel.canDeleteSeries.collectAsState()
|
||||||
val seasons by viewModel.seasons.observeAsState(listOf())
|
val seasons by viewModel.seasons.observeAsState(listOf())
|
||||||
val trailers by viewModel.trailers.observeAsState(listOf())
|
val trailers by viewModel.trailers.observeAsState(listOf())
|
||||||
val extras by viewModel.extras.observeAsState(listOf())
|
val extras by viewModel.extras.observeAsState(listOf())
|
||||||
val people by viewModel.people.observeAsState(listOf())
|
val people by viewModel.people.observeAsState(listOf())
|
||||||
val similar by viewModel.similar.observeAsState(listOf())
|
val similar by viewModel.similar.observeAsState(listOf())
|
||||||
val discovered by viewModel.discovered.collectAsState()
|
val discovered by viewModel.discovered.collectAsState()
|
||||||
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
|
||||||
var showWatchConfirmation by remember { mutableStateOf(false) }
|
var showWatchConfirmation by remember { mutableStateOf(false) }
|
||||||
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
|
||||||
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
|
|
||||||
when (val state = loading) {
|
when (val state = loading) {
|
||||||
is LoadingState.Error -> {
|
is LoadingState.Error -> {
|
||||||
|
|
@ -150,6 +160,7 @@ fun SeriesDetails(
|
||||||
similar = similar,
|
similar = similar,
|
||||||
played = played,
|
played = played,
|
||||||
favorite = item.data.userData?.isFavorite ?: false,
|
favorite = item.data.userData?.isFavorite ?: false,
|
||||||
|
canDelete = canDelete,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClickItem = { index, item ->
|
onClickItem = { index, item ->
|
||||||
viewModel.navigateTo(item.destination())
|
viewModel.navigateTo(item.destination())
|
||||||
|
|
@ -163,23 +174,29 @@ fun SeriesDetails(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClickItem = { index, season ->
|
onLongClickItem = { index, season ->
|
||||||
seasonDialog =
|
scope.launchDefault {
|
||||||
buildDialogForSeason(
|
seasonDialog =
|
||||||
context = context,
|
buildDialogForSeason(
|
||||||
s = season,
|
context = context,
|
||||||
onClickItem = { viewModel.navigateTo(it.destination()) },
|
s = season,
|
||||||
markPlayed = { played ->
|
canDelete = viewModel.canDelete(season),
|
||||||
viewModel.setSeasonWatched(season.id, played)
|
onClickItem = { viewModel.navigateTo(it.destination()) },
|
||||||
},
|
markPlayed = { played ->
|
||||||
onClickPlay = { shuffle ->
|
viewModel.setSeasonWatched(season.id, played)
|
||||||
viewModel.navigateTo(
|
},
|
||||||
Destination.PlaybackList(
|
onClickPlay = { shuffle ->
|
||||||
itemId = season.id,
|
viewModel.navigateTo(
|
||||||
shuffle = shuffle,
|
Destination.PlaybackList(
|
||||||
),
|
itemId = season.id,
|
||||||
)
|
shuffle = shuffle,
|
||||||
},
|
),
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onClickDelete = {
|
||||||
|
showDeleteDialog = it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
overviewDialog =
|
overviewDialog =
|
||||||
|
|
@ -231,6 +248,9 @@ fun SeriesDetails(
|
||||||
showPlaylistDialog.makePresent(itemId)
|
showPlaylistDialog.makePresent(itemId)
|
||||||
},
|
},
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onClickDelete = {
|
||||||
|
showDeleteDialog = it
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (showWatchConfirmation) {
|
if (showWatchConfirmation) {
|
||||||
|
|
@ -283,6 +303,19 @@ fun SeriesDetails(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showDeleteDialog?.let { item ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.title ?: "",
|
||||||
|
onCancel = { showDeleteDialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
if (seasons?.lastOrNull()?.id == item.id) {
|
||||||
|
focusManager.moveFocus(FocusDirection.Previous)
|
||||||
|
}
|
||||||
|
viewModel.deleteItem(item)
|
||||||
|
showDeleteDialog = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val HEADER_ROW = 0
|
private const val HEADER_ROW = 0
|
||||||
|
|
@ -305,6 +338,7 @@ fun SeriesDetailsContent(
|
||||||
discovered: List<DiscoverItem>,
|
discovered: List<DiscoverItem>,
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
|
canDelete: Boolean,
|
||||||
onClickItem: (Int, BaseItem) -> Unit,
|
onClickItem: (Int, BaseItem) -> Unit,
|
||||||
onClickPerson: (Person) -> Unit,
|
onClickPerson: (Person) -> Unit,
|
||||||
onLongClickItem: (Int, BaseItem) -> Unit,
|
onLongClickItem: (Int, BaseItem) -> Unit,
|
||||||
|
|
@ -436,6 +470,23 @@ fun SeriesDetailsContent(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (canDelete) {
|
||||||
|
DeleteButton(
|
||||||
|
onClick = {
|
||||||
|
position = HEADER_ROW
|
||||||
|
moreActions.onClickDelete.invoke(series)
|
||||||
|
},
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.onFocusChanged {
|
||||||
|
if (it.isFocused) {
|
||||||
|
scope.launch(ExceptionHandler()) {
|
||||||
|
bringIntoViewRequester.bringIntoView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
|
|
@ -638,9 +689,11 @@ fun SeriesDetailsHeader(
|
||||||
fun buildDialogForSeason(
|
fun buildDialogForSeason(
|
||||||
context: Context,
|
context: Context,
|
||||||
s: BaseItem,
|
s: BaseItem,
|
||||||
|
canDelete: Boolean,
|
||||||
onClickItem: (BaseItem) -> Unit,
|
onClickItem: (BaseItem) -> Unit,
|
||||||
markPlayed: (Boolean) -> Unit,
|
markPlayed: (Boolean) -> Unit,
|
||||||
onClickPlay: (Boolean) -> Unit,
|
onClickPlay: (Boolean) -> Unit,
|
||||||
|
onClickDelete: (BaseItem) -> Unit,
|
||||||
): DialogParams {
|
): DialogParams {
|
||||||
val items =
|
val items =
|
||||||
buildList {
|
buildList {
|
||||||
|
|
@ -679,6 +732,17 @@ fun buildDialogForSeason(
|
||||||
onClickPlay.invoke(true)
|
onClickPlay.invoke(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if (canDelete) {
|
||||||
|
add(
|
||||||
|
DialogItem(
|
||||||
|
context.getString(R.string.delete),
|
||||||
|
Icons.Default.Delete,
|
||||||
|
iconColor = Color.Red.copy(alpha = .8f),
|
||||||
|
) {
|
||||||
|
onClickDelete.invoke(s)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return DialogParams(
|
return DialogParams(
|
||||||
title = s.name ?: context.getString(R.string.tv_season),
|
title = s.name ?: context.getString(R.string.tv_season),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
import androidx.compose.runtime.livedata.observeAsState
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
|
@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
|
||||||
|
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogParams
|
import com.github.damontecres.wholphin.ui.components.DialogParams
|
||||||
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
import com.github.damontecres.wholphin.ui.components.DialogPopup
|
||||||
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
import com.github.damontecres.wholphin.ui.components.ErrorMessage
|
||||||
|
|
@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
|
||||||
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
|
||||||
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.rememberInt
|
import com.github.damontecres.wholphin.ui.rememberInt
|
||||||
import com.github.damontecres.wholphin.ui.seasonEpisode
|
import com.github.damontecres.wholphin.ui.seasonEpisode
|
||||||
|
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -89,6 +93,7 @@ fun SeriesOverview(
|
||||||
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val firstItemFocusRequester = remember { FocusRequester() }
|
val firstItemFocusRequester = remember { FocusRequester() }
|
||||||
val episodeRowFocusRequester = remember { FocusRequester() }
|
val episodeRowFocusRequester = remember { FocusRequester() }
|
||||||
val castCrewRowFocusRequester = remember { FocusRequester() }
|
val castCrewRowFocusRequester = remember { FocusRequester() }
|
||||||
|
|
@ -118,6 +123,7 @@ fun SeriesOverview(
|
||||||
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
|
||||||
|
|
||||||
var rowFocused by rememberInt()
|
var rowFocused by rememberInt()
|
||||||
|
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
|
||||||
|
|
||||||
LaunchedEffect(episodes) {
|
LaunchedEffect(episodes) {
|
||||||
episodes?.let { episodes ->
|
episodes?.let { episodes ->
|
||||||
|
|
@ -177,7 +183,7 @@ fun SeriesOverview(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun buildMoreForEpisode(
|
suspend fun buildMoreForEpisode(
|
||||||
ep: BaseItem,
|
ep: BaseItem,
|
||||||
chosenStreams: ChosenStreams?,
|
chosenStreams: ChosenStreams?,
|
||||||
fromLongClick: Boolean,
|
fromLongClick: Boolean,
|
||||||
|
|
@ -194,6 +200,7 @@ fun SeriesOverview(
|
||||||
seriesId = series.id,
|
seriesId = series.id,
|
||||||
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
|
||||||
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
|
||||||
|
canDelete = viewModel.canDelete(ep),
|
||||||
actions =
|
actions =
|
||||||
MoreDialogActions(
|
MoreDialogActions(
|
||||||
navigateTo = viewModel::navigateTo,
|
navigateTo = viewModel::navigateTo,
|
||||||
|
|
@ -216,6 +223,9 @@ fun SeriesOverview(
|
||||||
showPlaylistDialog = it
|
showPlaylistDialog = it
|
||||||
},
|
},
|
||||||
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
|
||||||
|
onClickDelete = {
|
||||||
|
showDeleteDialog = it
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onChooseVersion = {
|
onChooseVersion = {
|
||||||
chooseVersion =
|
chooseVersion =
|
||||||
|
|
@ -315,7 +325,9 @@ fun SeriesOverview(
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLongClick = { ep ->
|
onLongClick = { ep ->
|
||||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
scope.launchDefault {
|
||||||
|
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
playOnClick = { resume ->
|
playOnClick = { resume ->
|
||||||
rowFocused = EPISODE_ROW
|
rowFocused = EPISODE_ROW
|
||||||
|
|
@ -343,18 +355,22 @@ fun SeriesOverview(
|
||||||
},
|
},
|
||||||
moreOnClick = {
|
moreOnClick = {
|
||||||
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
|
||||||
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
scope.launchDefault {
|
||||||
|
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
overviewOnClick = {
|
overviewOnClick = {
|
||||||
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
episodeList?.getOrNull(position.episodeRowIndex)?.let {
|
||||||
overviewDialog =
|
scope.launchDefault {
|
||||||
ItemDetailsDialogInfo(
|
overviewDialog =
|
||||||
title = it.name ?: context.getString(R.string.unknown),
|
ItemDetailsDialogInfo(
|
||||||
overview = it.data.overview,
|
title = it.name ?: context.getString(R.string.unknown),
|
||||||
genres = it.data.genres.orEmpty(),
|
overview = it.data.overview,
|
||||||
files = it.data.mediaSources.orEmpty(),
|
genres = it.data.genres.orEmpty(),
|
||||||
)
|
files = it.data.mediaSources.orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
personOnClick = {
|
personOnClick = {
|
||||||
|
|
@ -420,6 +436,17 @@ fun SeriesOverview(
|
||||||
elevation = 3.dp,
|
elevation = 3.dp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
showDeleteDialog?.let { item ->
|
||||||
|
ConfirmDeleteDialog(
|
||||||
|
itemTitle = item.subtitle ?: "",
|
||||||
|
onCancel = { showDeleteDialog = null },
|
||||||
|
onConfirm = {
|
||||||
|
viewModel.deleteItem(item)
|
||||||
|
episodeRowFocusRequester.tryRequestFocus()
|
||||||
|
showDeleteDialog = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EPISODE_ROW = 0
|
private const val EPISODE_ROW = 0
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.MediaReportService
|
import com.github.damontecres.wholphin.services.MediaReportService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
|
|
@ -24,10 +25,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
import com.github.damontecres.wholphin.services.ThemeSongPlayer
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.services.UserPreferencesService
|
import com.github.damontecres.wholphin.services.UserPreferencesService
|
||||||
|
import com.github.damontecres.wholphin.services.deleteItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.SlimItemFields
|
||||||
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
|
||||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||||
import com.github.damontecres.wholphin.ui.gt
|
import com.github.damontecres.wholphin.ui.gt
|
||||||
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.letNotEmpty
|
import com.github.damontecres.wholphin.ui.letNotEmpty
|
||||||
import com.github.damontecres.wholphin.ui.lt
|
import com.github.damontecres.wholphin.ui.lt
|
||||||
|
|
@ -90,6 +93,7 @@ class SeriesViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
|
private val mediaManagementService: MediaManagementService,
|
||||||
@Assisted val seriesId: UUID,
|
@Assisted val seriesId: UUID,
|
||||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
@Assisted val seriesPageType: SeriesPageType,
|
@Assisted val seriesPageType: SeriesPageType,
|
||||||
|
|
@ -111,6 +115,7 @@ class SeriesViewModel
|
||||||
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
|
||||||
val people = MutableLiveData<List<Person>>(listOf())
|
val people = MutableLiveData<List<Person>>(listOf())
|
||||||
val similar = MutableLiveData<List<BaseItem>>()
|
val similar = MutableLiveData<List<BaseItem>>()
|
||||||
|
val canDeleteSeries = MutableStateFlow(false)
|
||||||
|
|
||||||
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
|
||||||
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
|
||||||
|
|
@ -127,6 +132,7 @@ class SeriesViewModel
|
||||||
Timber.v("Start")
|
Timber.v("Start")
|
||||||
addCloseable { themeSongPlayer.stop() }
|
addCloseable { themeSongPlayer.stop() }
|
||||||
val item = fetchItem(seriesId)
|
val item = fetchItem(seriesId)
|
||||||
|
canDeleteSeries.update { mediaManagementService.canDelete(item) }
|
||||||
backdropService.submit(item)
|
backdropService.submit(item)
|
||||||
|
|
||||||
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
|
||||||
|
|
@ -259,9 +265,12 @@ class SeriesViewModel
|
||||||
if (seriesPageType == SeriesPageType.DETAILS) {
|
if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
|
ItemFields.CAN_DELETE,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
null
|
listOf(
|
||||||
|
ItemFields.CAN_DELETE,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
val pager =
|
val pager =
|
||||||
|
|
@ -300,6 +309,7 @@ class SeriesViewModel
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.CUSTOM_RATING,
|
ItemFields.CUSTOM_RATING,
|
||||||
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
|
||||||
|
ItemFields.CAN_DELETE,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Timber.v(
|
Timber.v(
|
||||||
|
|
@ -551,6 +561,59 @@ class SeriesViewModel
|
||||||
lookUpChosenTracks(item.id, item)
|
lookUpChosenTracks(item.id, item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun deleteItem(item: BaseItem) {
|
||||||
|
deleteItem(context, mediaManagementService, item) {
|
||||||
|
viewModelScope.launchDefault {
|
||||||
|
if (item.type == BaseItemKind.SERIES) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
} else if (seriesPageType == SeriesPageType.DETAILS) {
|
||||||
|
this@SeriesViewModel.item.value?.let { series ->
|
||||||
|
val seasons = getSeasons(series, null).await()
|
||||||
|
if (seasons.isEmpty()) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
} else {
|
||||||
|
this@SeriesViewModel.seasons.setValueOnMain(seasons)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
position.value.let { (_, episodeIndex) ->
|
||||||
|
val eps = episodes.value as? EpisodeList.Success
|
||||||
|
if (eps != null) {
|
||||||
|
val pager = eps.episodes
|
||||||
|
val lastIndex = pager.lastIndex
|
||||||
|
pager.refreshPagesAfter(episodeIndex)
|
||||||
|
if (pager.isEmpty()) {
|
||||||
|
navigationManager.goBack()
|
||||||
|
} else {
|
||||||
|
if (episodeIndex == lastIndex) {
|
||||||
|
// Deleted last episode, so need to move left
|
||||||
|
episodes.setValueOnMain(
|
||||||
|
EpisodeList.Success(
|
||||||
|
eps.seasonId,
|
||||||
|
pager,
|
||||||
|
episodeIndex - 1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
|
||||||
|
} else {
|
||||||
|
episodes.setValueOnMain(
|
||||||
|
EpisodeList.Success(
|
||||||
|
eps.seasonId,
|
||||||
|
pager,
|
||||||
|
episodeIndex,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed interface EpisodeList {
|
sealed interface EpisodeList {
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,7 @@ fun HomeSettingsPage(
|
||||||
onClick = { type ->
|
onClick = { type ->
|
||||||
addRow { viewModel.addFavoriteRow(type) }
|
addRow { viewModel.addFavoriteRow(type) }
|
||||||
},
|
},
|
||||||
|
modifier = destModifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.ui.playback
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12
|
internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared seek acceleration profile for hold-to-seek behavior.
|
* Shared seek acceleration profile for hold-to-seek behavior.
|
||||||
|
|
|
||||||
|
|
@ -182,14 +182,10 @@ fun SeekBarDisplay(
|
||||||
KeyEventType.KeyDown -> {
|
KeyEventType.KeyDown -> {
|
||||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||||
if (repeatCount > 0) {
|
if (repeatCount > 0) {
|
||||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
|
||||||
leftHandledByRepeat = false
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
leftHandledByRepeat = true
|
leftHandledByRepeat = true
|
||||||
onLeft.invoke(
|
onLeft.invoke(
|
||||||
calculateSeekAccelerationMultiplier(
|
calculateSeekAccelerationMultiplier(
|
||||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
repeatCount = repeatCount,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -217,14 +213,10 @@ fun SeekBarDisplay(
|
||||||
KeyEventType.KeyDown -> {
|
KeyEventType.KeyDown -> {
|
||||||
val repeatCount = event.nativeKeyEvent.repeatCount
|
val repeatCount = event.nativeKeyEvent.repeatCount
|
||||||
if (repeatCount > 0) {
|
if (repeatCount > 0) {
|
||||||
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
|
|
||||||
rightHandledByRepeat = false
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
rightHandledByRepeat = true
|
rightHandledByRepeat = true
|
||||||
onRight.invoke(
|
onRight.invoke(
|
||||||
calculateSeekAccelerationMultiplier(
|
calculateSeekAccelerationMultiplier(
|
||||||
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
|
repeatCount = repeatCount,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
|
@ -382,7 +383,10 @@ fun AddServerCard(
|
||||||
Surface(
|
Surface(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
modifier = Modifier.size(cardSize),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(cardSize)
|
||||||
|
.testTag("add_server"),
|
||||||
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
|
||||||
colors =
|
colors =
|
||||||
ClickableSurfaceDefaults.colors(
|
ClickableSurfaceDefaults.colors(
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.input.ImeAction
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||||
|
|
@ -352,6 +353,7 @@ fun SwitchServerContent(
|
||||||
),
|
),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
|
.testTag("server_url_text")
|
||||||
.focusRequester(textBoxFocusRequester)
|
.focusRequester(textBoxFocusRequester)
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
import com.github.damontecres.wholphin.ui.launchIO
|
import com.github.damontecres.wholphin.ui.launchIO
|
||||||
import com.github.damontecres.wholphin.ui.setValueOnMain
|
import com.github.damontecres.wholphin.ui.setValueOnMain
|
||||||
import com.github.damontecres.wholphin.ui.showToast
|
import com.github.damontecres.wholphin.ui.showToast
|
||||||
import com.github.damontecres.wholphin.util.LoadingState
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
@ -42,6 +45,8 @@ class SwitchServerViewModel
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
val serverDao: JellyfinServerDao,
|
val serverDao: JellyfinServerDao,
|
||||||
val navigationManager: SetupNavigationManager,
|
val navigationManager: SetupNavigationManager,
|
||||||
|
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
|
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
|
||||||
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
|
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())
|
||||||
|
|
|
||||||
|
|
@ -27,18 +27,18 @@ object StreamFormatting {
|
||||||
resolutionString(height, width, interlaced)
|
resolutionString(height, width, interlaced)
|
||||||
} else {
|
} else {
|
||||||
when {
|
when {
|
||||||
width <= 256 && height <= 144 -> "144" + interlaced(interlaced)
|
width > 5120 || height > 4320 -> "8K"
|
||||||
width <= 426 && height <= 240 -> "240" + interlaced(interlaced)
|
width > 2560 || height > 1440 -> "4K"
|
||||||
width <= 640 && height <= 360 -> "360" + interlaced(interlaced)
|
width > 1920 || height > 1080 -> "1440" + interlaced(interlaced)
|
||||||
width <= 682 && height <= 384 -> "384" + interlaced(interlaced)
|
width > 1280 || height > 962 -> "1080" + interlaced(interlaced)
|
||||||
width <= 720 && height <= 404 -> "404" + interlaced(interlaced)
|
width > 1024 || height > 576 -> "720" + interlaced(interlaced)
|
||||||
width <= 854 && height <= 480 -> "480" + interlaced(interlaced)
|
width > 960 || height > 544 -> "576" + interlaced(interlaced)
|
||||||
width <= 960 && height <= 544 -> "540" + interlaced(interlaced)
|
width > 845 || height > 480 -> "540" + interlaced(interlaced)
|
||||||
width <= 1024 && height <= 576 -> "576" + interlaced(interlaced)
|
width > 720 || height > 404 -> "480" + interlaced(interlaced)
|
||||||
width <= 1280 && height <= 962 -> "720" + interlaced(interlaced)
|
width > 682 || height > 384 -> "404" + interlaced(interlaced)
|
||||||
width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced)
|
width > 640 || height > 360 -> "384" + interlaced(interlaced)
|
||||||
width <= 4096 && height <= 3072 -> "4K"
|
width > 426 || height > 240 -> "360" + interlaced(interlaced)
|
||||||
width <= 8192 && height <= 6144 -> "8K"
|
width > 256 || height > 144 -> "240" + interlaced(interlaced)
|
||||||
else -> height.toString() + interlaced(interlaced)
|
else -> height.toString() + interlaced(interlaced)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,22 @@ class ApiRequestPager<T>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dumps the cache for all the pages at or after the given position and fetches a new page
|
||||||
|
*/
|
||||||
|
suspend fun refreshPagesAfter(position: Int) {
|
||||||
|
val pageNumber = position / pageSize
|
||||||
|
cachedPages.asMap().apply {
|
||||||
|
keys.forEach { pageKey ->
|
||||||
|
if (pageKey >= pageNumber) {
|
||||||
|
if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey)
|
||||||
|
remove(pageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPageBlocking(position, true)
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val DEBUG = false
|
private const val DEBUG = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.services.hilt.AppModule
|
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
||||||
import com.google.auto.service.AutoService
|
import com.google.auto.service.AutoService
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
|
@ -49,7 +50,7 @@ class CrashReportSender : ReportSender {
|
||||||
createJellyfin {
|
createJellyfin {
|
||||||
this.context = context
|
this.context = context
|
||||||
clientInfo = AppModule.clientInfo(context)
|
clientInfo = AppModule.clientInfo(context)
|
||||||
deviceInfo = AppModule.deviceInfo(context)
|
deviceInfo = DeviceModule.deviceInfo(context)
|
||||||
apiClientFactory = okHttpFactory
|
apiClientFactory = okHttpFactory
|
||||||
socketConnectionFactory = okHttpFactory
|
socketConnectionFactory = okHttpFactory
|
||||||
minimumServerVersion = Jellyfin.minimumVersion
|
minimumServerVersion = Jellyfin.minimumVersion
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,7 @@ void *event_thread(void *arg)
|
||||||
case MPV_EVENT_END_FILE:
|
case MPV_EVENT_END_FILE:
|
||||||
mp_end_file = (mpv_event_end_file*)mp_event->data;
|
mp_end_file = (mpv_event_end_file*)mp_event->data;
|
||||||
sendEndFileEventToJava(env, mp_end_file);
|
sendEndFileEventToJava(env, mp_end_file);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
|
||||||
sendEventToJava(env, mp_event->event_id);
|
sendEventToJava(env, mp_event->event_id);
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ message InterfacePreferences {
|
||||||
BackdropStyle backdrop_style = 9;
|
BackdropStyle backdrop_style = 9;
|
||||||
SubtitlePreferences hdr_subtitles_preferences = 10;
|
SubtitlePreferences hdr_subtitles_preferences = 10;
|
||||||
ScreensaverPreferences screensaver_preference = 11;
|
ScreensaverPreferences screensaver_preference = 11;
|
||||||
|
bool enable_media_management = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AdvancedPreferences {
|
message AdvancedPreferences {
|
||||||
|
|
|
||||||
|
|
@ -700,6 +700,8 @@
|
||||||
<string name="no_subtitles_found">No remote subtitles were found</string>
|
<string name="no_subtitles_found">No remote subtitles were found</string>
|
||||||
<string name="series_continueing">Present</string>
|
<string name="series_continueing">Present</string>
|
||||||
<string name="in_app_screensaver">Use in-app screensaver</string>
|
<string name="in_app_screensaver">Use in-app screensaver</string>
|
||||||
|
<string name="delete_item">Are you sure you want to delete this item?</string>
|
||||||
|
<string name="show_media_management">Show media management options</string>
|
||||||
<string-array name="backdrop_style_options">
|
<string-array name="backdrop_style_options">
|
||||||
<item>@string/backdrop_style_dynamic</item>
|
<item>@string/backdrop_style_dynamic</item>
|
||||||
<item>@string/backdrop_style_image</item>
|
<item>@string/backdrop_style_image</item>
|
||||||
|
|
@ -712,6 +714,8 @@
|
||||||
<item>@string/photos</item>
|
<item>@string/photos</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
|
|
||||||
|
<string name="search_for">Search %s</string>
|
||||||
|
|
||||||
<string name="actor">Actor</string>
|
<string name="actor">Actor</string>
|
||||||
<string name="composer">Composer</string>
|
<string name="composer">Composer</string>
|
||||||
<string name="writer">Writer</string>
|
<string name="writer">Writer</string>
|
||||||
|
|
@ -730,4 +734,5 @@
|
||||||
<string name="play_next">Play next</string>
|
<string name="play_next">Play next</string>
|
||||||
<string name="music_videos">Music videos</string>
|
<string name="music_videos">Music videos</string>
|
||||||
|
|
||||||
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser
|
||||||
import com.github.damontecres.wholphin.data.ServerRepository
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
import com.github.damontecres.wholphin.data.model.JellyfinServer
|
||||||
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
import com.github.damontecres.wholphin.data.model.JellyfinUser
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
|
@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest {
|
||||||
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
private val currentLiveData = MutableLiveData<CurrentUser?>()
|
||||||
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
|
||||||
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
|
||||||
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
|
|
||||||
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
|
||||||
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
|
||||||
|
|
||||||
|
|
@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest {
|
||||||
SuggestionsSchedulerService(
|
SuggestionsSchedulerService(
|
||||||
context = mockActivity,
|
context = mockActivity,
|
||||||
serverRepository = mockServerRepository,
|
serverRepository = mockServerRepository,
|
||||||
cache = mockCache,
|
|
||||||
workManager = mockWorkManager,
|
workManager = mockWorkManager,
|
||||||
).also { it.dispatcher = testDispatcher }
|
).also {
|
||||||
|
it.dispatcher = testDispatcher
|
||||||
|
it.initialDelaySecondsProvider = { 60L }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>()
|
||||||
|
every { future.get() } returns infos
|
||||||
|
every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_when_user_present() =
|
fun schedules_periodic_work_when_user_present() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun cancels_work_when_user_null() =
|
fun cancels_work_when_user_null() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
createService()
|
createService()
|
||||||
currentLiveData.value =
|
currentLiveData.value =
|
||||||
CurrentUser(
|
CurrentUser(
|
||||||
|
|
@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest {
|
||||||
@Test
|
@Test
|
||||||
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
|
||||||
runTest {
|
runTest {
|
||||||
|
mockWorkInfos(emptyList())
|
||||||
val workRequestSlot = slot<PeriodicWorkRequest>()
|
val workRequestSlot = slot<PeriodicWorkRequest>()
|
||||||
every {
|
every {
|
||||||
mockWorkManager.enqueueUniquePeriodicWork(
|
mockWorkManager.enqueueUniquePeriodicWork(
|
||||||
|
|
@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest {
|
||||||
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
|
||||||
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun does_not_schedule_if_already_scheduled_for_same_user() =
|
||||||
|
runTest {
|
||||||
|
val userId = UUID.randomUUID()
|
||||||
|
val workInfo = mockk<androidx.work.WorkInfo>()
|
||||||
|
every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED
|
||||||
|
every { workInfo.tags } returns setOf("user:$userId")
|
||||||
|
mockWorkInfos(listOf(workInfo))
|
||||||
|
|
||||||
|
createService()
|
||||||
|
currentLiveData.value =
|
||||||
|
CurrentUser(
|
||||||
|
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
|
||||||
|
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ class SuggestionsWorkerTest {
|
||||||
every { mockApi.userViewsApi } returns mockUserViewsApi
|
every { mockApi.userViewsApi } returns mockUserViewsApi
|
||||||
every { mockApi.baseUrl } returns "http://localhost"
|
every { mockApi.baseUrl } returns "http://localhost"
|
||||||
every { mockApi.accessToken } returns "test-token"
|
every { mockApi.accessToken } returns "test-token"
|
||||||
|
coEvery { mockCache.get(any(), any(), any()) } returns null
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.github.damontecres.wholphin.test
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FormattingTests {
|
||||||
|
@Test
|
||||||
|
fun testResolutionStrings() {
|
||||||
|
Assert.assertEquals("4K", resolutionString(3840, 2160, false))
|
||||||
|
Assert.assertEquals("1080p", resolutionString(1920, 1080, false))
|
||||||
|
Assert.assertEquals("720p", resolutionString(1280, 720, false))
|
||||||
|
Assert.assertEquals("480i", resolutionString(640, 480, true))
|
||||||
|
|
||||||
|
Assert.assertEquals("576p", resolutionString(1024, 576, false))
|
||||||
|
Assert.assertEquals("576p", resolutionString(960, 576, false))
|
||||||
|
|
||||||
|
// 21:9
|
||||||
|
Assert.assertEquals("1080p", resolutionString(1920, 822, false))
|
||||||
|
|
||||||
|
Assert.assertEquals("1440p", resolutionString(2560, 1440, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,238 @@
|
||||||
|
package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsFocused
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.performTextInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import androidx.compose.ui.test.requestFocus
|
||||||
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.ScreensaverState
|
||||||
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
import com.github.damontecres.wholphin.test.TestActivity
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
|
||||||
|
import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.util.LoadingState
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidRule
|
||||||
|
import dagger.hilt.android.testing.HiltAndroidTest
|
||||||
|
import dagger.hilt.android.testing.HiltTestApplication
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.quickConnectApi
|
||||||
|
import org.jellyfin.sdk.api.operations.QuickConnectApi
|
||||||
|
import org.jellyfin.sdk.discovery.DiscoveryService
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfo
|
||||||
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
|
import org.jellyfin.sdk.model.UUID
|
||||||
|
import org.jellyfin.sdk.model.api.PublicSystemInfo
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltAndroidTest
|
||||||
|
@Config(application = HiltTestApplication::class, sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class BasicUiTests {
|
||||||
|
@get:Rule(order = 0)
|
||||||
|
var hiltRule = HiltAndroidRule(this)
|
||||||
|
|
||||||
|
@get:Rule(order = 1)
|
||||||
|
val composeTestRule = createAndroidComposeRule<TestActivity>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var jellyfin: Jellyfin
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var api: ApiClient
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var setupNavigationManager: SetupNavigationManager
|
||||||
|
|
||||||
|
lateinit var screensaverService: ScreensaverService
|
||||||
|
|
||||||
|
lateinit var switchServerViewModel: SwitchServerViewModel
|
||||||
|
|
||||||
|
val discovery: DiscoveryService = mockk()
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
Dispatchers.setMain(TestModule.testDispatcher)
|
||||||
|
mockkStatic(Dispatchers::class)
|
||||||
|
|
||||||
|
every { Dispatchers.IO } returns TestModule.testDispatcher
|
||||||
|
every { Dispatchers.Default } returns TestModule.testDispatcher
|
||||||
|
|
||||||
|
hiltRule.inject()
|
||||||
|
screensaverService = mockk(relaxed = true)
|
||||||
|
every { screensaverService.state } returns
|
||||||
|
MutableStateFlow(
|
||||||
|
ScreensaverState(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api
|
||||||
|
every { jellyfin.discovery } returns discovery
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests successfully entering and submitting a server URL
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun test_enter_server_url_success() {
|
||||||
|
coEvery { discovery.getRecommendedServers("localhost") } returns
|
||||||
|
listOf(
|
||||||
|
RecommendedServerInfo(
|
||||||
|
address = "localhost",
|
||||||
|
responseTime = 50,
|
||||||
|
score = RecommendedServerInfoScore.GREAT,
|
||||||
|
issues = emptyList(),
|
||||||
|
systemInfo =
|
||||||
|
Result.success(
|
||||||
|
PublicSystemInfo(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
startupWizardCompleted = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val quickConnectApi = mockk<QuickConnectApi>()
|
||||||
|
every { api.quickConnectApi } returns quickConnectApi
|
||||||
|
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
WholphinTheme {
|
||||||
|
switchServerViewModel = hiltViewModel()
|
||||||
|
SwitchServerContent(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
viewModel = switchServerViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("add_server").performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown) // TODO fix focus
|
||||||
|
}
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("add_server")
|
||||||
|
.assertIsFocused()
|
||||||
|
.performClickEnter()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
|
||||||
|
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
|
||||||
|
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
|
||||||
|
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
switchServerViewModel.addServerState.value.let {
|
||||||
|
if (it is LoadingState.Error) throw it.exception ?: Exception(it.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") }
|
||||||
|
Assert.assertEquals(1, setupNavigationManager.backStack.size)
|
||||||
|
Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests entering and submitting a server URL that returns an error
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
@Test
|
||||||
|
fun test_enter_server_url_error() {
|
||||||
|
coEvery { discovery.getRecommendedServers("localhost") } returns
|
||||||
|
listOf(
|
||||||
|
RecommendedServerInfo(
|
||||||
|
address = "localhost",
|
||||||
|
responseTime = 50,
|
||||||
|
score = RecommendedServerInfoScore.GREAT,
|
||||||
|
issues = emptyList(),
|
||||||
|
systemInfo =
|
||||||
|
Result.success(
|
||||||
|
PublicSystemInfo(
|
||||||
|
id = null, // Invalid
|
||||||
|
startupWizardCompleted = false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val quickConnectApi = mockk<QuickConnectApi>()
|
||||||
|
every { api.quickConnectApi } returns quickConnectApi
|
||||||
|
coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true)
|
||||||
|
|
||||||
|
composeTestRule.setContent {
|
||||||
|
WholphinTheme {
|
||||||
|
switchServerViewModel = hiltViewModel()
|
||||||
|
SwitchServerContent(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
viewModel = switchServerViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Add Server").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithTag("add_server").performKeyInput {
|
||||||
|
pressKey(Key.DirectionDown) // TODO fix focus
|
||||||
|
}
|
||||||
|
composeTestRule
|
||||||
|
.onNodeWithTag("add_server")
|
||||||
|
.assertIsFocused()
|
||||||
|
.performClickEnter()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText("Enter server address").performClickEnter()
|
||||||
|
composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed()
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost")
|
||||||
|
composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter()
|
||||||
|
|
||||||
|
TestModule.testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,282 @@
|
||||||
|
package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.core.DataStoreFactory
|
||||||
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
|
import androidx.datastore.dataStoreFile
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.github.damontecres.wholphin.BuildConfig
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.AppDatabase
|
||||||
|
import com.github.damontecres.wholphin.data.ItemPlaybackDao
|
||||||
|
import com.github.damontecres.wholphin.data.JellyfinServerDao
|
||||||
|
import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao
|
||||||
|
import com.github.damontecres.wholphin.data.Migrations
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackEffectDao
|
||||||
|
import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao
|
||||||
|
import com.github.damontecres.wholphin.data.SeerrServerDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerPreferencesDao
|
||||||
|
import com.github.damontecres.wholphin.data.ServerRepository
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
|
import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrApi
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AppModule
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DatabaseModule
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.DeviceModule
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
|
||||||
|
import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient
|
||||||
|
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
|
||||||
|
import com.github.damontecres.wholphin.util.RememberTabManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import dagger.hilt.testing.TestInstallIn
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.asExecutor
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.jellyfin.sdk.Jellyfin
|
||||||
|
import org.jellyfin.sdk.JellyfinOptions
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder
|
||||||
|
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||||
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [DeviceModule::class, AppModule::class],
|
||||||
|
)
|
||||||
|
object TestModule {
|
||||||
|
val testDispatcher = StandardTestDispatcher()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun deviceInfo(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DeviceInfo = DeviceInfo("test_device_id", "test_device")
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun clientInfo(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): ClientInfo =
|
||||||
|
ClientInfo(
|
||||||
|
name = context.getString(R.string.app_name),
|
||||||
|
version = BuildConfig.VERSION_NAME,
|
||||||
|
)
|
||||||
|
|
||||||
|
@StandardOkHttpClient
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun okHttpClient() =
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.apply {
|
||||||
|
// TODO user agent, timeouts, logging, etc
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
@AuthOkHttpClient
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun authOkHttpClient(
|
||||||
|
serverRepository: ServerRepository,
|
||||||
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
|
clientInfo: ClientInfo,
|
||||||
|
deviceInfo: DeviceInfo,
|
||||||
|
) = okHttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.addInterceptor {
|
||||||
|
val request = it.request()
|
||||||
|
val newRequest =
|
||||||
|
serverRepository.currentUser.value?.accessToken?.let { token ->
|
||||||
|
request
|
||||||
|
.newBuilder()
|
||||||
|
.addHeader(
|
||||||
|
"Authorization",
|
||||||
|
AuthorizationHeaderBuilder.buildHeader(
|
||||||
|
clientName = clientInfo.name,
|
||||||
|
clientVersion = clientInfo.version,
|
||||||
|
deviceId = deviceInfo.id,
|
||||||
|
deviceName = deviceInfo.name,
|
||||||
|
accessToken = token,
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
it.proceed(newRequest ?: request)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun okHttpFactory(
|
||||||
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
|
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun jellyfin(
|
||||||
|
okHttpFactory: CoroutineContextApiClientFactory,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
clientInfo: ClientInfo,
|
||||||
|
deviceInfo: DeviceInfo,
|
||||||
|
): Jellyfin {
|
||||||
|
val jellyfin: Jellyfin = mockk()
|
||||||
|
every { jellyfin.clientInfo } returns clientInfo
|
||||||
|
every { jellyfin.deviceInfo } returns deviceInfo
|
||||||
|
every { jellyfin.options } returns
|
||||||
|
JellyfinOptions(
|
||||||
|
context,
|
||||||
|
clientInfo,
|
||||||
|
deviceInfo,
|
||||||
|
okHttpFactory,
|
||||||
|
okHttpFactory,
|
||||||
|
Jellyfin.minimumVersion,
|
||||||
|
)
|
||||||
|
every { jellyfin.createApi(any()) } returns apiClient(jellyfin)
|
||||||
|
every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin)
|
||||||
|
return jellyfin
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun apiClient(jellyfin: Jellyfin): ApiClient {
|
||||||
|
val api: ApiClient = mockk()
|
||||||
|
every { api.clientInfo } returns jellyfin.clientInfo!!
|
||||||
|
every { api.deviceInfo } returns jellyfin.deviceInfo!!
|
||||||
|
every { api.update(any(), any(), any(), any()) } returns Unit
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of [RememberTabManager] which remembers by server, user, & item
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun rememberTabManager(
|
||||||
|
serverRepository: ServerRepository,
|
||||||
|
appPreference: DataStore<AppPreferences>,
|
||||||
|
@IoCoroutineScope scope: CoroutineScope,
|
||||||
|
): RememberTabManager = mockk()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@IoDispatcher
|
||||||
|
fun ioDispatcher(): CoroutineDispatcher = testDispatcher
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@IoCoroutineScope
|
||||||
|
fun ioCoroutineScope(
|
||||||
|
@IoDispatcher dispatcher: CoroutineDispatcher,
|
||||||
|
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@DefaultDispatcher
|
||||||
|
fun defaultDispatcher(): CoroutineDispatcher = testDispatcher
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@DefaultCoroutineScope
|
||||||
|
fun defaultCoroutineScope(
|
||||||
|
@DefaultDispatcher dispatcher: CoroutineDispatcher,
|
||||||
|
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun workManager(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): WorkManager = mockk()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seerrApi(
|
||||||
|
@StandardOkHttpClient okHttpClient: OkHttpClient,
|
||||||
|
): SeerrApi = mockk()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@TestInstallIn(
|
||||||
|
components = [SingletonComponent::class],
|
||||||
|
replaces = [DatabaseModule::class],
|
||||||
|
)
|
||||||
|
object TestDatabaseModule {
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DatabaseModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun database(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): AppDatabase =
|
||||||
|
Room
|
||||||
|
.inMemoryDatabaseBuilder(
|
||||||
|
context,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
).addMigrations(Migrations.Migrate2to3)
|
||||||
|
.allowMainThreadQueries()
|
||||||
|
.setQueryCallback({ sqlQuery, args ->
|
||||||
|
Timber.v("sqlQuery=$sqlQuery, args=$args")
|
||||||
|
}, Dispatchers.IO.asExecutor())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun userPreferencesDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
userPreferencesSerializer: AppPreferencesSerializer,
|
||||||
|
): DataStore<AppPreferences> =
|
||||||
|
DataStoreFactory.create(
|
||||||
|
serializer = userPreferencesSerializer,
|
||||||
|
produceFile = { context.dataStoreFile("app_preferences.pb") },
|
||||||
|
corruptionHandler =
|
||||||
|
ReplaceFileCorruptionHandler(
|
||||||
|
produceNewData = { AppPreferences.getDefaultInstance() },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.github.damontecres.wholphin.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.test.ExperimentalTestApi
|
||||||
|
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||||
|
import androidx.compose.ui.test.performKeyInput
|
||||||
|
import androidx.compose.ui.test.pressKey
|
||||||
|
import org.jellyfin.sdk.api.client.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTestApi::class)
|
||||||
|
fun SemanticsNodeInteraction.performClickEnter() =
|
||||||
|
performKeyInput {
|
||||||
|
pressKey(Key.DirectionCenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun <T> successResponse(content: T) = Response(content, 200, emptyMap())
|
||||||
|
|
@ -12,7 +12,7 @@ kotlin = "2.3.10"
|
||||||
ksp = "2.3.6"
|
ksp = "2.3.6"
|
||||||
coreKtx = "1.17.0"
|
coreKtx = "1.17.0"
|
||||||
appcompat = "1.7.1"
|
appcompat = "1.7.1"
|
||||||
composeBom = "2026.02.00"
|
composeBom = "2026.02.01"
|
||||||
mockk = "1.14.9"
|
mockk = "1.14.9"
|
||||||
robolectric = "4.16.1"
|
robolectric = "4.16.1"
|
||||||
multiplatformMarkdownRenderer = "0.39.2"
|
multiplatformMarkdownRenderer = "0.39.2"
|
||||||
|
|
@ -33,7 +33,7 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
|
||||||
protobuf = "0.9.6"
|
protobuf = "0.9.6"
|
||||||
datastore = "1.2.0"
|
datastore = "1.2.0"
|
||||||
kotlinx-serialization = "1.10.0"
|
kotlinx-serialization = "1.10.0"
|
||||||
protobuf-javalite = "4.33.5"
|
protobuf-javalite = "4.34.0"
|
||||||
hilt = "2.59.2"
|
hilt = "2.59.2"
|
||||||
room = "2.8.4"
|
room = "2.8.4"
|
||||||
preferenceKtx = "1.2.1"
|
preferenceKtx = "1.2.1"
|
||||||
|
|
@ -43,6 +43,7 @@ paletteKtx = "1.0.0"
|
||||||
kotlinxCoroutinesTest = "1.10.2"
|
kotlinxCoroutinesTest = "1.10.2"
|
||||||
coreTesting = "2.2.0"
|
coreTesting = "2.2.0"
|
||||||
openapi-generator = "7.20.0"
|
openapi-generator = "7.20.0"
|
||||||
|
runner = "1.7.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
|
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
|
||||||
|
|
@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
|
||||||
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
|
||||||
|
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||||
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||||
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
|
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
|
||||||
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
|
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
|
||||||
|
|
@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
|
||||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||||
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||||
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
|
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
|
||||||
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
|
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
|
||||||
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
|
||||||
|
|
@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio
|
||||||
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
|
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
|
||||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||||
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
|
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
|
||||||
|
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue