Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-03-03 19:15:37 -05:00
commit 59a0bee0a7
No known key found for this signature in database
51 changed files with 1745 additions and 274 deletions

View file

@ -74,7 +74,7 @@ jobs:
echo "SHA256 checksums:"
find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum
- name: Upload AAB
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: AAB
path: |

View file

@ -44,7 +44,7 @@ android {
targetSdk = 36
versionCode = gitTags.trim().lines().size
versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner"
}
signingConfigs {
@ -140,6 +140,12 @@ android {
kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin"
}
}
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
}
protobuf {
@ -254,6 +260,7 @@ dependencies {
implementation(libs.androidx.room.testing)
implementation(libs.androidx.palette.ktx)
implementation(libs.androidx.media3.effect)
implementation(libs.androidx.runner)
ksp(libs.androidx.room.compiler)
ksp(libs.hilt.android.compiler)
ksp(libs.androidx.hilt.compiler)
@ -292,4 +299,9 @@ dependencies {
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.core.testing)
testImplementation(libs.robolectric)
testImplementation(libs.hilt.android.testing)
testImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.mockk.android)
androidTestImplementation(libs.hilt.android.testing)
androidTestImplementation(libs.androidx.ui.test.manifest)
}

View file

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

View file

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

View file

@ -0,0 +1,14 @@
package com.github.damontecres.wholphin.test
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class WholphinTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader?,
name: String?,
context: Context?,
): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View file

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

View file

@ -8,12 +8,9 @@ import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
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.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.unit.dp
import androidx.datastore.core.DataStore
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.tv.material3.ExperimentalTvMaterial3Api
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.AppUpgradeHandler
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.DatePlayedInvalidationService
@ -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.ui.CoilConfig
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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setup.SwitchServerContent
import com.github.damontecres.wholphin.ui.setup.SwitchUserContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import com.github.damontecres.wholphin.util.DebugLogTree
import com.github.damontecres.wholphin.util.ExceptionHandler
import dagger.hilt.android.AndroidEntryPoint
@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -87,7 +68,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUID
import org.jellyfin.sdk.model.serializer.toUUIDOrNull
import timber.log.Timber
import javax.inject.Inject
@ -231,134 +211,19 @@ class MainActivity : AppCompatActivity() {
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
) {
Surface(
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
shape = RectangleShape,
) {
// val backStack = rememberNavBackStack(SetupDestination.Loading)
// setupNavigationManager.backStack = backStack
val backStack = setupNavigationManager.backStack
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
entryDecorators =
listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
entryProvider = { key ->
key as SetupDestination
NavEntry(key) {
when (key) {
SetupDestination.Loading -> {
Box(
modifier = Modifier.size(200.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.border,
modifier = Modifier.align(Alignment.Center),
)
}
}
SetupDestination.ServerList -> {
SwitchServerContent(Modifier.fillMaxSize())
}
is SetupDestination.UserList -> {
SwitchUserContent(
currentServer = key.server,
Modifier.fillMaxSize(),
)
}
is SetupDestination.AppContent -> {
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)
intent?.let(::extractDestination) ?: Destination.Home()
}
ApplicationContent(
user = current.user,
server = current.server,
startDestination =
requestedDestination
// ?: Destination.Home(),
// TODO undo
?: Destination.MediaItem(
itemId = "011ef0c7ca45684f2cd9dd3b020ca5f6".toUUID(),
type = BaseItemKind.MUSIC_ALBUM,
),
MainContent(
backStack = setupNavigationManager.backStack,
navigationManager = navigationManager,
preferences = preferences,
appPreferences = appPreferences,
backdropService = backdropService,
screensaverService = screensaverService,
requestedDestination = requestedDestination,
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())
}
}
}
}
}
}
@ -406,6 +271,22 @@ class MainActivity : AppCompatActivity() {
override fun onStart() {
super.onStart()
Timber.d("onStart")
lifecycleScope.launchDefault {
val appPreferences = userPreferencesDataStore.data.first()
if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) {
try {
updateChecker.maybeShowUpdateToast(
appPreferences.updateUrl,
)
} catch (ex: Exception) {
Timber.w(
ex,
"Exception during update check",
)
}
}
}
}
override fun onSaveInstanceState(outState: Bundle) {

View 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())
}
}
}
}

View file

@ -62,13 +62,13 @@ class WholphinApplication :
.penaltyDeathOnNetwork()
.build(),
)
StrictMode.setVmPolicy(
StrictMode.VmPolicy
.Builder()
.detectAll()
.penaltyLog()
.build(),
)
// StrictMode.setVmPolicy(
// StrictMode.VmPolicy
// .Builder()
// .detectAll()
// .penaltyLog()
// .build(),
// )
}
OkHttp.initialize(this)
initAcra {

View file

@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.components.AppScreensaverContent
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.util.ProvideLocalClock
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import javax.inject.Inject
@ -69,6 +70,7 @@ class WholphinDreamService :
}
prefs?.let { prefs ->
WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) {
ProvideLocalClock {
val screensaverPrefs =
prefs.appPreferences.interfacePreferences.screensaverPreference
val currentItem by itemFlow.collectAsState(null)
@ -82,6 +84,7 @@ class WholphinDreamService :
}
}
}
}
},
)
}

View file

@ -9,10 +9,12 @@ import androidx.lifecycle.map
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.EqualityMutableLiveData
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
@ -41,6 +43,7 @@ class ServerRepository
val serverDao: JellyfinServerDao,
val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current
@ -57,7 +60,7 @@ class ServerRepository
* The current user is removed
*/
suspend fun addAndChangeServer(server: JellyfinServer) {
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.addOrUpdateServer(server)
}
apiClient.update(baseUrl = server.url, accessToken = null)
@ -71,7 +74,7 @@ class ServerRepository
server: JellyfinServer,
user: JellyfinUser,
): CurrentUser? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
if (server.id != user.serverId) {
throw IllegalStateException("User is not part of the server")
}
@ -126,7 +129,7 @@ class ServerRepository
return null
}
val serverAndUsers =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.getServer(serverId)
}
if (serverAndUsers != null) {
@ -149,7 +152,7 @@ class ServerRepository
}
suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverId?.let { serverDao.getServer(serverId)?.server }
}
@ -163,7 +166,7 @@ class ServerRepository
suspend fun changeUser(
serverUrl: String,
authenticationResult: AuthenticationResult,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val accessToken = authenticationResult.accessToken
if (accessToken != null) {
val authedUser = authenticationResult.user
@ -213,7 +216,7 @@ class ServerRepository
}
apiClient.update(accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteUser(user.serverId, user.id)
}
}
@ -233,7 +236,7 @@ class ServerRepository
}
apiClient.update(baseUrl = null, accessToken = null)
}
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
serverDao.deleteServer(server.id)
}
}
@ -252,7 +255,7 @@ class ServerRepository
suspend fun setUserPin(
user: JellyfinUser,
pin: String?,
) = withContext(Dispatchers.IO) {
) = withContext(ioDispatcher) {
val newUser = user.copy(pin = pin)
val updatedUser = serverDao.addOrUpdateUser(newUser)
if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) {
@ -265,7 +268,7 @@ class ServerRepository
}
suspend fun authorizeQuickConnect(code: String): Boolean =
withContext(Dispatchers.IO) {
withContext(ioDispatcher) {
val userId = currentUser.value?.id
if (userId == null) {
Timber.e("No user logged in for Quick Connect authorization")

View file

@ -72,6 +72,8 @@ data class BaseItem(
}
}
val canDelete: Boolean get() = data.canDelete == true
@Transient
val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 }

View file

@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> {
valueToIndex = { it.number },
)
val ManageMedia =
AppSwitchPreference<AppPreferences>(
title = R.string.show_media_management,
defaultValue = false,
getter = { it.interfacePreferences.enableMediaManagement },
setter = { prefs, value ->
prefs.updateInterfacePreferences { enableMediaManagement = value }
},
summaryOn = R.string.enabled,
summaryOff = R.string.disabled,
)
val OneClickPause =
AppSwitchPreference<AppPreferences>(
title = R.string.one_click_pause,
@ -1110,6 +1122,7 @@ val advancedPreferences =
preferences =
listOf(
AppPreference.ShowClock,
AppPreference.ManageMedia,
AppPreference.CombineContinueNext,
// Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418
// AppPreference.NavDrawerSwitchOnFocus,

View file

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

View file

@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.liveTvApi
import org.jellyfin.sdk.api.client.extensions.userViewsApi
@ -176,7 +178,9 @@ class NavDrawerService
val allItems = builtins + libraries
val navDrawerPins =
withContext(Dispatchers.IO) {
serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId }
}
val items = mutableListOf<NavDrawerItem>()
val moreItems = mutableListOf<NavDrawerItem>()

View file

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

View file

@ -8,6 +8,7 @@ import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.github.damontecres.wholphin.data.ServerRepository
@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import kotlin.random.Random
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
@ -31,7 +34,6 @@ class SuggestionsSchedulerService
constructor(
@param:ActivityContext private val context: Context,
private val serverRepository: ServerRepository,
private val cache: SuggestionsCache,
private val workManager: WorkManager,
) {
private val activity =
@ -42,6 +44,7 @@ class SuggestionsSchedulerService
// Exposed for testing
internal var dispatcher: CoroutineDispatcher = Dispatchers.IO
internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) }
init {
serverRepository.current.observe(activity) { user ->
@ -60,6 +63,28 @@ class SuggestionsSchedulerService
userId: UUID,
serverId: UUID,
) {
val workInfos =
withContext(dispatcher) {
workManager
.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME)
.get()
}
val activeWork =
workInfos.firstOrNull {
!it.state.isFinished
}
val scheduledUserId =
activeWork
?.tags
?.firstOrNull { it.startsWith("user:") }
?.removePrefix("user:")
val isAlreadyScheduledForUser = scheduledUserId == userId.toString()
if (isAlreadyScheduledForUser) {
Timber.d("SuggestionsWorker already scheduled for user %s", userId)
return
}
val constraints =
Constraints
.Builder()
@ -75,12 +100,18 @@ class SuggestionsSchedulerService
val periodicWorkRequestBuilder =
PeriodicWorkRequestBuilder<SuggestionsWorker>(
repeatInterval = 12.hours.toJavaDuration(),
flexTimeInterval = 1.hours.toJavaDuration(),
).setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
15.minutes.toJavaDuration(),
).setInputData(inputData)
.setInitialDelay(60.seconds.toJavaDuration())
.addTag("user:$userId")
val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L)
periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration())
Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay")
workManager.enqueueUniquePeriodicWork(
uniqueWorkName = SuggestionsWorker.WORK_NAME,

View file

@ -99,11 +99,17 @@ class SuggestionsWorker
itemsPerRow,
)
ensureActive()
val newIds = suggestions.map { it.id }
val cachedIds = cache.get(userId, view.id, itemKind)?.ids
if (cachedIds == newIds) {
Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id)
return@runCatching
}
cache.put(
userId,
view.id,
itemKind,
suggestions.map { it.id },
newIds,
)
}.onFailure { e ->
Timber.e(
@ -242,7 +248,7 @@ class SuggestionsWorker
GetItemsRequest(
parentId = parentId,
userId = userId,
fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields,
fields = extraFields,
includeItemTypes = listOf(itemKind),
genreIds = genreIds,
recursive = true,
@ -252,7 +258,7 @@ class SuggestionsWorker
sortOrder = sortOrder?.let { listOf(it) },
limit = limit,
enableTotalRecordCount = false,
imageTypeLimit = 1,
imageTypeLimit = 0,
)
return GetItemsRequestHandler
.execute(api, request)

View file

@ -18,6 +18,7 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -49,6 +50,14 @@ annotation class IoCoroutineScope
@Retention(AnnotationRetention.BINARY)
annotation class DefaultCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@ -62,12 +71,6 @@ object AppModule {
version = BuildConfig.VERSION_NAME,
)
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
@StandardOkHttpClient
@Provides
@Singleton
@ -177,15 +180,29 @@ object AppModule {
}
}
@Provides
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@Singleton
@IoCoroutineScope
fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun ioCoroutineScope(
@IoDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@DefaultDispatcher
fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides
@Singleton
@DefaultCoroutineScope
fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun defaultCoroutineScope(
@DefaultDispatcher dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@Provides
@Singleton
@ -199,3 +216,13 @@ object AppModule {
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = SeerrApi(okHttpClient)
}
@Module
@InstallIn(SingletonComponent::class)
object DeviceModule {
@Provides
@Singleton
fun deviceInfo(
@ApplicationContext context: Context,
): DeviceInfo = androidDevice(context)
}

View file

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

View file

@ -165,6 +165,7 @@ fun AppScreensaverContent(
) {
Text(
text = currentItem?.title ?: "",
color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.displaySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,

View file

@ -60,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.ThemeSongPlayer
@ -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.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageHeader
import com.github.damontecres.wholphin.ui.nav.Destination
@ -128,6 +130,7 @@ class CollectionFolderViewModel
private val navigationManager: NavigationManager,
private val themeSongPlayer: ThemeSongPlayer,
private val userPreferencesService: UserPreferencesService,
private val mediaManagementService: MediaManagementService,
val mediaReportService: MediaReportService,
@Assisted itemId: String,
@Assisted initialSortAndDirection: SortAndDirection?,
@ -199,6 +202,19 @@ class CollectionFolderViewModel
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(

View file

@ -6,6 +6,8 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.TrackIndex
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream
import com.github.damontecres.wholphin.ui.playback.isDown
import com.github.damontecres.wholphin.ui.playback.isUp
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -259,31 +271,65 @@ fun DialogPopupContent(
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val scope = rememberCoroutineScope()
val listState = rememberLazyListState()
val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } }
LazyColumn(
state = listState,
modifier = Modifier,
) {
items(dialogItems) {
when (it) {
itemsIndexed(dialogItems) { index, item ->
when (item) {
is DialogItemDivider -> {
HorizontalDivider(Modifier.height(16.dp))
}
is DialogItem -> {
val interactionSource = remember { MutableInteractionSource() }
val focused by interactionSource.collectIsFocusedAsState()
ListItem(
selected = it.selected,
enabled = !waiting && it.enabled,
selected = item.selected,
enabled = !waiting && item.enabled,
onClick = {
if (dismissOnClick) {
onDismissRequest.invoke()
}
it.onClick.invoke()
item.onClick.invoke()
},
headlineContent = it.headlineContent,
overlineContent = it.overlineContent,
supportingContent = it.supportingContent,
leadingContent = it.leadingContent,
trailingContent = it.trailingContent,
modifier = Modifier,
headlineContent = item.headlineContent,
overlineContent = item.overlineContent,
supportingContent = item.supportingContent,
leadingContent = item.leadingContent,
trailingContent = item.trailingContent,
interactionSource = interactionSource,
modifier =
Modifier
.focusRequester(focusRequesters[index])
.ifElse(
index == 0,
Modifier.onKeyEvent {
if (focused && isUp(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(dialogItems.lastIndex)
focusRequesters[dialogItems.lastIndex].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
).ifElse(
index == dialogItems.lastIndex,
Modifier.onKeyEvent {
if (focused && isDown(it) && it.type == KeyEventType.KeyDown) {
scope.launch {
listState.animateScrollToItem(0)
focusRequesters[0].tryRequestFocus()
}
return@onKeyEvent true
}
false
},
),
)
}
}
@ -471,6 +517,80 @@ fun ConfirmDialogContent(
}
}
@Composable
fun ConfirmDeleteDialog(
itemTitle: String,
onCancel: () -> Unit,
onConfirm: () -> Unit,
) {
BasicDialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
LazyColumn(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(16.dp),
modifier = Modifier.wrapContentWidth(),
) {
item {
Text(
text = stringResource(R.string.delete_item),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
item {
Text(
text = itemTitle,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(bottom = 8.dp),
)
}
item {
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier,
) {
Button(
onClick = onCancel,
modifier = Modifier.width(72.dp),
) {
Text(
text = stringResource(R.string.cancel),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
}
Button(
onClick = onConfirm,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp),
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = null,
tint = Color.Red.copy(alpha = .8f),
modifier = Modifier,
)
Text(
text = stringResource(R.string.confirm),
)
}
}
}
}
}
}
}
fun chooseVersionParams(
context: Context,
sources: List<MediaSourceInfo>,
@ -536,6 +656,9 @@ fun chooseStream(
)
add(
DialogItem(
leadingContent = {
SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED)
},
headlineContent = {
Text(text = stringResource(R.string.only_forced_subtitles))
},

View file

@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components
import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.painterResource
@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
@ -234,7 +241,7 @@ fun ExpandablePlayButton(
fun ExpandablePlayButton(
@StringRes title: Int,
resume: Duration,
icon: @Composable () -> Unit,
icon: @Composable BoxScope.() -> Unit,
onClick: (position: Duration) -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
@ -254,12 +261,13 @@ fun ExpandablePlayButton(
interactionSource = interactionSource,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.padding(start = 2.dp, top = 2.dp)
.padding(start = 2.dp)
.height(MinButtonSize),
) {
icon.invoke()
icon.invoke(this)
}
AnimatedVisibility(isFocused) {
Spacer(Modifier.size(8.dp))
@ -359,6 +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
@Composable
private fun ExpandablePlayButtonsPreview() {
@ -417,12 +467,19 @@ private fun ViewOptionsPreview() {
onClick = {},
modifier = Modifier,
)
DeleteButton(
onClick = {},
)
SortByButton(
sortOptions = listOf(),
current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING),
onSortChange = {},
)
}
DeleteButton(
onClick = {},
interactionSource = source,
)
}
}
}

View file

@ -29,6 +29,7 @@ data class MoreDialogActions(
val onClickFavorite: (UUID, Boolean) -> Unit,
val onClickAddPlaylist: (UUID) -> Unit,
val onSendMediaInfo: (UUID) -> Unit,
val onClickDelete: (BaseItem) -> Unit = {},
)
enum class ClearChosenStreams {
@ -62,6 +63,7 @@ fun buildMoreDialogItems(
watched: Boolean,
favorite: Boolean,
canClearChosenStreams: Boolean,
canDelete: Boolean = false,
actions: MoreDialogActions,
onChooseVersion: () -> Unit,
onChooseTracks: (MediaStreamType) -> Unit,
@ -139,6 +141,17 @@ fun buildMoreDialogItems(
actions.onClickAddPlaylist.invoke(item.id)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,
@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome(
playbackPosition: Duration,
watched: Boolean,
favorite: Boolean,
canDelete: Boolean = false,
actions: MoreDialogActions,
): List<DialogItem> =
buildList {
@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome(
actions.onClickAddPlaylist.invoke(itemId)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
actions.onClickDelete.invoke(item)
},
)
}
add(
DialogItem(
text = if (watched) R.string.mark_unwatched else R.string.mark_watched,

View file

@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.cards.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -111,6 +112,7 @@ fun MovieDetails(
var chooseVersion by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
val moreActions =
MoreDialogActions(
@ -126,6 +128,7 @@ fun MovieDetails(
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = { showDeleteDialog = it },
)
when (val state = loading) {
@ -201,6 +204,7 @@ fun MovieDetails(
seriesId = null,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete,
actions = moreActions,
onChooseVersion = {
chooseVersion =
@ -291,6 +295,7 @@ fun MovieDetails(
playbackPosition = similar.playbackPosition,
watched = similar.played,
favorite = similar.favorite,
canDelete = false,
actions = moreActions,
)
moreDialog =
@ -362,6 +367,16 @@ fun MovieDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0

View file

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

View file

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

View file

@ -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.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@ -24,12 +25,14 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@ -53,7 +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.PersonRow
import com.github.damontecres.wholphin.ui.cards.SeasonCard
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.ConfirmDialog
import com.github.damontecres.wholphin.ui.components.DeleteButton
import com.github.damontecres.wholphin.ui.components.DialogItem
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
@ -76,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson
import com.github.damontecres.wholphin.ui.discover.DiscoverRow
import com.github.damontecres.wholphin.ui.discover.DiscoverRowData
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
@ -102,21 +108,25 @@ fun SeriesDetails(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val canDelete by viewModel.canDeleteSeries.collectAsState()
val seasons by viewModel.seasons.observeAsState(listOf())
val trailers by viewModel.trailers.observeAsState(listOf())
val extras by viewModel.extras.observeAsState(listOf())
val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var showWatchConfirmation by remember { mutableStateOf(false) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) }
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
when (val state = loading) {
is LoadingState.Error -> {
@ -150,6 +160,7 @@ fun SeriesDetails(
similar = similar,
played = played,
favorite = item.data.userData?.isFavorite ?: false,
canDelete = canDelete,
modifier = modifier,
onClickItem = { index, item ->
viewModel.navigateTo(item.destination())
@ -163,10 +174,12 @@ fun SeriesDetails(
)
},
onLongClickItem = { index, season ->
scope.launchDefault {
seasonDialog =
buildDialogForSeason(
context = context,
s = season,
canDelete = viewModel.canDelete(season),
onClickItem = { viewModel.navigateTo(it.destination()) },
markPlayed = { played ->
viewModel.setSeasonWatched(season.id, played)
@ -179,7 +192,11 @@ fun SeriesDetails(
),
)
},
onClickDelete = {
showDeleteDialog = it
},
)
}
},
overviewOnClick = {
overviewDialog =
@ -231,6 +248,9 @@ fun SeriesDetails(
showPlaylistDialog.makePresent(itemId)
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
),
)
if (showWatchConfirmation) {
@ -283,6 +303,19 @@ fun SeriesDetails(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.title ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
if (seasons?.lastOrNull()?.id == item.id) {
focusManager.moveFocus(FocusDirection.Previous)
}
viewModel.deleteItem(item)
showDeleteDialog = null
},
)
}
}
private const val HEADER_ROW = 0
@ -305,6 +338,7 @@ fun SeriesDetailsContent(
discovered: List<DiscoverItem>,
played: Boolean,
favorite: Boolean,
canDelete: Boolean,
onClickItem: (Int, BaseItem) -> Unit,
onClickPerson: (Person) -> 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 {
@ -638,9 +689,11 @@ fun SeriesDetailsHeader(
fun buildDialogForSeason(
context: Context,
s: BaseItem,
canDelete: Boolean,
onClickItem: (BaseItem) -> Unit,
markPlayed: (Boolean) -> Unit,
onClickPlay: (Boolean) -> Unit,
onClickDelete: (BaseItem) -> Unit,
): DialogParams {
val items =
buildList {
@ -679,6 +732,17 @@ fun buildDialogForSeason(
onClickPlay.invoke(true)
},
)
if (canDelete) {
add(
DialogItem(
context.getString(R.string.delete),
Icons.Default.Delete,
iconColor = Color.Red.copy(alpha = .8f),
) {
onClickDelete.invoke(s)
},
)
}
}
return DialogParams(
title = s.name ?: context.getString(R.string.tv_season),

View file

@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams
import com.github.damontecres.wholphin.ui.components.DialogPopup
import com.github.damontecres.wholphin.ui.components.ErrorMessage
@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions
import com.github.damontecres.wholphin.ui.detail.PlaylistDialog
import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState
import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.rememberInt
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.LoadingState
import kotlinx.coroutines.flow.update
import kotlinx.serialization.Serializable
@ -89,6 +93,7 @@ fun SeriesOverview(
playlistViewModel: AddPlaylistViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstItemFocusRequester = remember { FocusRequester() }
val episodeRowFocusRequester = remember { FocusRequester() }
val castCrewRowFocusRequester = remember { FocusRequester() }
@ -118,6 +123,7 @@ fun SeriesOverview(
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var rowFocused by rememberInt()
var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) }
LaunchedEffect(episodes) {
episodes?.let { episodes ->
@ -177,7 +183,7 @@ fun SeriesOverview(
}
}
fun buildMoreForEpisode(
suspend fun buildMoreForEpisode(
ep: BaseItem,
chosenStreams: ChosenStreams?,
fromLongClick: Boolean,
@ -194,6 +200,7 @@ fun SeriesOverview(
seriesId = series.id,
sourceId = chosenStreams?.source?.id?.toUUIDOrNull(),
canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null,
canDelete = viewModel.canDelete(ep),
actions =
MoreDialogActions(
navigateTo = viewModel::navigateTo,
@ -216,6 +223,9 @@ fun SeriesOverview(
showPlaylistDialog = it
},
onSendMediaInfo = viewModel.mediaReportService::sendReportFor,
onClickDelete = {
showDeleteDialog = it
},
),
onChooseVersion = {
chooseVersion =
@ -315,7 +325,9 @@ fun SeriesOverview(
)
},
onLongClick = { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, true)
}
},
playOnClick = { resume ->
rowFocused = EPISODE_ROW
@ -343,11 +355,14 @@ fun SeriesOverview(
},
moreOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let { ep ->
scope.launchDefault {
moreDialog = buildMoreForEpisode(ep, chosenStreams, false)
}
}
},
overviewOnClick = {
episodeList?.getOrNull(position.episodeRowIndex)?.let {
scope.launchDefault {
overviewDialog =
ItemDetailsDialogInfo(
title = it.name ?: context.getString(R.string.unknown),
@ -356,6 +371,7 @@ fun SeriesOverview(
files = it.data.mediaSources.orEmpty(),
)
}
}
},
personOnClick = {
rowFocused =
@ -420,6 +436,17 @@ fun SeriesOverview(
elevation = 3.dp,
)
}
showDeleteDialog?.let { item ->
ConfirmDeleteDialog(
itemTitle = item.subtitle ?: "",
onCancel = { showDeleteDialog = null },
onConfirm = {
viewModel.deleteItem(item)
episodeRowFocusRequester.tryRequestFocus()
showDeleteDialog = null
},
)
}
}
private const val EPISODE_ROW = 0

View file

@ -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.ExtrasService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.MediaReportService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
@ -24,10 +25,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService
import com.github.damontecres.wholphin.services.ThemeSongPlayer
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.services.deleteItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.gt
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.letNotEmpty
import com.github.damontecres.wholphin.ui.lt
@ -90,6 +93,7 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val seerrService: SeerrService,
private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@Assisted val seriesPageType: SeriesPageType,
@ -111,6 +115,7 @@ class SeriesViewModel
val extras = MutableLiveData<List<ExtrasItem>>(listOf())
val people = MutableLiveData<List<Person>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val canDeleteSeries = MutableStateFlow(false)
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
@ -127,6 +132,7 @@ class SeriesViewModel
Timber.v("Start")
addCloseable { themeSongPlayer.stop() }
val item = fetchItem(seriesId)
canDeleteSeries.update { mediaManagementService.canDelete(item) }
backdropService.submit(item)
val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber)
@ -259,9 +265,12 @@ class SeriesViewModel
if (seriesPageType == SeriesPageType.DETAILS) {
listOf(
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
)
} else {
null
listOf(
ItemFields.CAN_DELETE,
)
},
)
val pager =
@ -300,6 +309,7 @@ class SeriesViewModel
ItemFields.OVERVIEW,
ItemFields.CUSTOM_RATING,
ItemFields.PRIMARY_IMAGE_ASPECT_RATIO,
ItemFields.CAN_DELETE,
),
)
Timber.v(
@ -551,6 +561,59 @@ class SeriesViewModel
lookUpChosenTracks(item.id, item)
}
}
fun deleteItem(item: BaseItem) {
deleteItem(context, mediaManagementService, item) {
viewModelScope.launchDefault {
if (item.type == BaseItemKind.SERIES) {
navigationManager.goBack()
} else if (seriesPageType == SeriesPageType.DETAILS) {
this@SeriesViewModel.item.value?.let { series ->
val seasons = getSeasons(series, null).await()
if (seasons.isEmpty()) {
navigationManager.goBack()
} else {
this@SeriesViewModel.seasons.setValueOnMain(seasons)
}
}
} else {
position.value.let { (_, episodeIndex) ->
val eps = episodes.value as? EpisodeList.Success
if (eps != null) {
val pager = eps.episodes
val lastIndex = pager.lastIndex
pager.refreshPagesAfter(episodeIndex)
if (pager.isEmpty()) {
navigationManager.goBack()
} else {
if (episodeIndex == lastIndex) {
// Deleted last episode, so need to move left
episodes.setValueOnMain(
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex - 1,
),
)
position.update { it.copy(episodeRowIndex = episodeIndex - 1) }
} else {
episodes.setValueOnMain(
EpisodeList.Success(
eps.seasonId,
pager,
episodeIndex,
),
)
}
}
}
}
}
}
}
}
suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item)
}
sealed interface EpisodeList {

View file

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

View file

@ -1,6 +1,6 @@
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.

View file

@ -182,14 +182,10 @@ fun SeekBarDisplay(
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
if (repeatCount > 0) {
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
leftHandledByRepeat = false
return@onPreviewKeyEvent true
}
leftHandledByRepeat = true
onLeft.invoke(
calculateSeekAccelerationMultiplier(
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
repeatCount = repeatCount,
durationMs = durationMs,
),
)
@ -217,14 +213,10 @@ fun SeekBarDisplay(
KeyEventType.KeyDown -> {
val repeatCount = event.nativeKeyEvent.repeatCount
if (repeatCount > 0) {
if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) {
rightHandledByRepeat = false
return@onPreviewKeyEvent true
}
rightHandledByRepeat = true
onRight.invoke(
calculateSeekAccelerationMultiplier(
repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT,
repeatCount = repeatCount,
durationMs = durationMs,
),
)

View file

@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -382,7 +383,10 @@ fun AddServerCard(
Surface(
onClick = onClick,
interactionSource = interactionSource,
modifier = Modifier.size(cardSize),
modifier =
Modifier
.size(cardSize)
.testTag("add_server"),
shape = ClickableSurfaceDefaults.shape(shape = CircleShape),
colors =
ClickableSurfaceDefaults.colors(

View file

@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@ -352,6 +353,7 @@ fun SwitchServerContent(
),
modifier =
Modifier
.testTag("server_url_text")
.focusRequester(textBoxFocusRequester)
.fillMaxWidth(),
)

View file

@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher
import com.github.damontecres.wholphin.services.hilt.IoDispatcher
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@ -42,6 +45,8 @@ class SwitchServerViewModel
val serverRepository: ServerRepository,
val serverDao: JellyfinServerDao,
val navigationManager: SetupNavigationManager,
@param:IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() {
val servers = MutableLiveData<List<JellyfinServer>>(listOf())
val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf())

View file

@ -27,18 +27,18 @@ object StreamFormatting {
resolutionString(height, width, interlaced)
} else {
when {
width <= 256 && height <= 144 -> "144" + interlaced(interlaced)
width <= 426 && height <= 240 -> "240" + interlaced(interlaced)
width <= 640 && height <= 360 -> "360" + interlaced(interlaced)
width <= 682 && height <= 384 -> "384" + interlaced(interlaced)
width <= 720 && height <= 404 -> "404" + interlaced(interlaced)
width <= 854 && height <= 480 -> "480" + interlaced(interlaced)
width <= 960 && height <= 544 -> "540" + interlaced(interlaced)
width <= 1024 && height <= 576 -> "576" + interlaced(interlaced)
width <= 1280 && height <= 962 -> "720" + interlaced(interlaced)
width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced)
width <= 4096 && height <= 3072 -> "4K"
width <= 8192 && height <= 6144 -> "8K"
width > 5120 || height > 4320 -> "8K"
width > 2560 || height > 1440 -> "4K"
width > 1920 || height > 1080 -> "1440" + interlaced(interlaced)
width > 1280 || height > 962 -> "1080" + interlaced(interlaced)
width > 1024 || height > 576 -> "720" + interlaced(interlaced)
width > 960 || height > 544 -> "576" + interlaced(interlaced)
width > 845 || height > 480 -> "540" + interlaced(interlaced)
width > 720 || height > 404 -> "480" + interlaced(interlaced)
width > 682 || height > 384 -> "404" + interlaced(interlaced)
width > 640 || height > 360 -> "384" + interlaced(interlaced)
width > 426 || height > 240 -> "360" + interlaced(interlaced)
width > 256 || height > 144 -> "240" + interlaced(interlaced)
else -> height.toString() + interlaced(interlaced)
}
}

View file

@ -166,6 +166,22 @@ class ApiRequestPager<T>(
}
}
/**
* Dumps the cache for all the pages at or after the given position and fetches a new page
*/
suspend fun refreshPagesAfter(position: Int) {
val pageNumber = position / pageSize
cachedPages.asMap().apply {
keys.forEach { pageKey ->
if (pageKey >= pageNumber) {
if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey)
remove(pageKey)
}
}
}
fetchPageBlocking(position, true)
}
companion object {
private const val DEBUG = false
}

View file

@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util
import android.content.Context
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.services.hilt.AppModule
import com.github.damontecres.wholphin.services.hilt.DeviceModule
import com.google.auto.service.AutoService
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
@ -49,7 +50,7 @@ class CrashReportSender : ReportSender {
createJellyfin {
this.context = context
clientInfo = AppModule.clientInfo(context)
deviceInfo = AppModule.deviceInfo(context)
deviceInfo = DeviceModule.deviceInfo(context)
apiClientFactory = okHttpFactory
socketConnectionFactory = okHttpFactory
minimumServerVersion = Jellyfin.minimumVersion

View file

@ -107,6 +107,7 @@ void *event_thread(void *arg)
case MPV_EVENT_END_FILE:
mp_end_file = (mpv_event_end_file*)mp_event->data;
sendEndFileEventToJava(env, mp_end_file);
break;
default:
ALOGV("event: %s\n", mpv_event_name(mp_event->event_id));
sendEventToJava(env, mp_event->event_id);

View file

@ -166,6 +166,7 @@ message InterfacePreferences {
BackdropStyle backdrop_style = 9;
SubtitlePreferences hdr_subtitles_preferences = 10;
ScreensaverPreferences screensaver_preference = 11;
bool enable_media_management = 12;
}
message AdvancedPreferences {

View file

@ -700,6 +700,8 @@
<string name="no_subtitles_found">No remote subtitles were found</string>
<string name="series_continueing">Present</string>
<string name="in_app_screensaver">Use in-app screensaver</string>
<string name="delete_item">Are you sure you want to delete this item?</string>
<string name="show_media_management">Show media management options</string>
<string-array name="backdrop_style_options">
<item>@string/backdrop_style_dynamic</item>
<item>@string/backdrop_style_image</item>
@ -712,6 +714,8 @@
<item>@string/photos</item>
</string-array>
<string name="search_for">Search %s</string>
<string name="actor">Actor</string>
<string name="composer">Composer</string>
<string name="writer">Writer</string>
@ -730,4 +734,5 @@
<string name="play_next">Play next</string>
<string name="music_videos">Music videos</string>
</resources>

View file

@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.JellyfinServer
import com.github.damontecres.wholphin.data.model.JellyfinUser
import com.google.common.util.concurrent.ListenableFuture
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest {
private val currentLiveData = MutableLiveData<CurrentUser?>()
private val mockActivity = mockk<AppCompatActivity>(relaxed = true)
private val mockServerRepository = mockk<ServerRepository>(relaxed = true)
private val mockCache = mockk<SuggestionsCache>(relaxed = true)
private val mockWorkManager = mockk<WorkManager>(relaxed = true)
private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true))
@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest {
SuggestionsSchedulerService(
context = mockActivity,
serverRepository = mockServerRepository,
cache = mockCache,
workManager = mockWorkManager,
).also { it.dispatcher = testDispatcher }
).also {
it.dispatcher = testDispatcher
it.initialDelaySecondsProvider = { 60L }
}
private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) {
@Suppress("UNCHECKED_CAST")
val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>()
every { future.get() } returns infos
every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future
}
@Test
fun schedules_periodic_work_when_user_present() =
runTest {
mockWorkInfos(emptyList())
createService()
currentLiveData.value =
CurrentUser(
@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest {
@Test
fun cancels_work_when_user_null() =
runTest {
mockWorkInfos(emptyList())
createService()
currentLiveData.value =
CurrentUser(
@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_with_delay_when_cache_empty() =
runTest {
mockWorkInfos(emptyList())
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest {
@Test
fun schedules_periodic_work_with_delay_when_cache_not_empty() =
runTest {
mockWorkInfos(emptyList())
val workRequestSlot = slot<PeriodicWorkRequest>()
every {
mockWorkManager.enqueueUniquePeriodicWork(
@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest {
verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) }
assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay)
}
@Test
fun does_not_schedule_if_already_scheduled_for_same_user() =
runTest {
val userId = UUID.randomUUID()
val workInfo = mockk<androidx.work.WorkInfo>()
every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED
every { workInfo.tags } returns setOf("user:$userId")
mockWorkInfos(listOf(workInfo))
createService()
currentLiveData.value =
CurrentUser(
user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"),
server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null),
)
advanceUntilIdle()
verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) }
}
}

View file

@ -46,6 +46,7 @@ class SuggestionsWorkerTest {
every { mockApi.userViewsApi } returns mockUserViewsApi
every { mockApi.baseUrl } returns "http://localhost"
every { mockApi.accessToken } returns "test-token"
coEvery { mockCache.get(any(), any(), any()) } returns null
}
@After

View file

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

View file

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

View file

@ -0,0 +1,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()
}
}

View file

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

View file

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

View file

@ -12,7 +12,7 @@ kotlin = "2.3.10"
ksp = "2.3.6"
coreKtx = "1.17.0"
appcompat = "1.7.1"
composeBom = "2026.02.00"
composeBom = "2026.02.01"
mockk = "1.14.9"
robolectric = "4.16.1"
multiplatformMarkdownRenderer = "0.39.2"
@ -33,7 +33,7 @@ material3AdaptiveNav3 = "1.0.0-alpha03"
protobuf = "0.9.6"
datastore = "1.2.0"
kotlinx-serialization = "1.10.0"
protobuf-javalite = "4.33.5"
protobuf-javalite = "4.34.0"
hilt = "2.59.2"
room = "2.8.4"
preferenceKtx = "1.2.1"
@ -43,6 +43,7 @@ paletteKtx = "1.0.0"
kotlinxCoroutinesTest = "1.10.2"
coreTesting = "2.2.0"
openapi-generator = "7.20.0"
runner = "1.7.0"
[libraries]
aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" }
@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" }
androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" }
androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" }
androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" }
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" }
kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" }
mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" }
@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio
androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" }
androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }