From ee440698b00d5498bb62971ea3a97fe73fdb9149 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Feb 2026 18:22:11 -0500 Subject: [PATCH 1/3] Set a different style for HDR subtitles & adjust image subtitles opacity (#827) ## Description Can now configure a different subtitle style for HDR playback. There is also a new setting to change the opacity/dim image based subtitles. This is primarily for HDR to dim very bright PGS subtitles, but the setting is available for SDR too. Please remember that the styles only apply to unstyled text formats like SRT. I haven't been able to consistently get the preview to switch the display into HDR mode, so the preview is not 100% accurate. ### Related issues Closes #583 ### Testing Shield pro 2019 w/ LG C2 ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 26 ++- .../wholphin/preferences/AppPreference.kt | 4 +- .../preferences/AppPreferencesSerializer.kt | 7 + .../wholphin/services/AppUpgradeHandler.kt | 15 ++ .../wholphin/services/RefreshRateService.kt | 2 +- .../wholphin/ui/nav/Destination.kt | 5 + .../wholphin/ui/nav/DestinationContent.kt | 9 + .../wholphin/ui/playback/PlaybackPage.kt | 28 ++- .../ui/preferences/PreferenceUtils.kt | 15 +- .../ui/preferences/PreferencesContent.kt | 13 -- .../subtitle/SubtitlePreferencesContent.kt | 195 ++++++++++++++++++ .../preferences/subtitle/SubtitleSettings.kt | 119 +++++++---- .../preferences/subtitle/SubtitleStylePage.kt | 119 +++++++++-- app/src/main/proto/WholphinDataStore.proto | 2 + app/src/main/res/values/strings.xml | 2 + 15 files changed, 475 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index af88392e..567f8117 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -21,6 +21,7 @@ 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 @@ -57,6 +58,7 @@ 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.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent @@ -70,6 +72,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -133,7 +136,9 @@ class MainActivity : AppCompatActivity() { Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { - appUpgradeHandler.copySubfont(false) + lifecycleScope.launchIO { + appUpgradeHandler.copySubfont(false) + } } viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { @@ -148,6 +153,25 @@ class MainActivity : AppCompatActivity() { viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) + if (appPreferences == null) { + // Show loading page if it is taking a while to get app preferences + var showLoading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(500) + Timber.i("Showing loading page") + showLoading = true + } + if (showLoading) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black), + ) { + LoadingPage() + } + } + } appPreferences?.let { appPreferences -> LaunchedEffect(appPreferences.signInAutomatically) { signInAuto = appPreferences.signInAutomatically diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 70e83692..0472be5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -710,7 +710,7 @@ sealed interface AppPreference { val SubtitleStyle = AppDestinationPreference( title = R.string.subtitle_style, - destination = Destination.Settings(PreferenceScreenOption.SUBTITLES), + destination = Destination.SubtitleSettings(false), ) val RefreshRateSwitching = @@ -969,8 +969,6 @@ val basicPreferences = ), ) -val uiPreferences = listOf() - private val ExoPlayerSettings = listOf( AppPreference.FfmpegPreference, diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index f84744c7..ca343804 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -100,6 +100,12 @@ class AppPreferencesSerializer .apply { resetSubtitles() }.build() + hdrSubtitlesPreferences = + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() liveTvPreferences = LiveTvPreferences @@ -193,4 +199,5 @@ fun SubtitlePreferences.Builder.resetSubtitles() { backgroundStyle = SubtitleSettings.BackgroundStylePref.defaultValue margin = SubtitleSettings.Margin.defaultValue.toInt() edgeThickness = SubtitleSettings.EdgeThickness.defaultValue.toInt() + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index d6fbea40..7e911767 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -213,4 +213,19 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-6-g0"))) { + appPreferences.updateData { + it.updateInterfacePreferences { + subtitlesPreferences = + subtitlesPreferences + .toBuilder() + .apply { + imageSubtitleOpacity = SubtitleSettings.ImageOpacity.defaultValue.toInt() + }.build() + // Copy current subtitle prefs as HDR ones + hdrSubtitlesPreferences = subtitlesPreferences.toBuilder().build() + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt index d961d4c5..bc0c8f94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/RefreshRateService.kt @@ -29,7 +29,7 @@ class RefreshRateService @param:ApplicationContext private val context: Context, ) { private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY) val supportedDisplayModes get() = display.supportedModes.orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 426d5328..e6f3636d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -38,6 +38,11 @@ sealed class Destination( val screen: PreferenceScreenOption, ) : Destination(true) + @Serializable + data class SubtitleSettings( + val hdr: Boolean, + ) : Destination(true) + @Serializable data object Search : Destination() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 70ef71d2..09fd3936 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage +import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -78,6 +79,14 @@ fun DestinationContent( ) } + is Destination.SubtitleSettings -> { + SubtitleStylePage( + preferences.appPreferences, + destination.hdr, + modifier, + ) + } + is Destination.SeriesOverview -> { SeriesOverview( preferences = preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt index fde12a22..f8b80020 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt @@ -34,6 +34,7 @@ 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -70,6 +71,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.TextButton +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.applyToMpv import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize @@ -85,6 +87,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jellyfin.sdk.model.extensions.ticks +import timber.log.Timber import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -176,6 +179,7 @@ fun PlaybackPageContent( LaunchedEffect(player) { if (playerBackend == PlayerBackend.MPV) { scope.launch(Dispatchers.IO + ExceptionHandler()) { + // MPV can't play HDR, so always use regular settings preferences.appPreferences.interfacePreferences.subtitlesPreferences.applyToMpv( configuration, density, @@ -401,13 +405,26 @@ fun PlaybackPageContent( ) } + val subtitleSettings = + remember(mediaInfo) { + Timber.v("subtitle choice: ${mediaInfo?.videoStream?.hdr}") + if (mediaInfo?.videoStream?.hdr == true) { + preferences.appPreferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.appPreferences.interfacePreferences.subtitlesPreferences + } + } + val subtitleImageOpacity = + remember(subtitleSettings) { subtitleSettings.imageSubtitleOpacity / 100f } + // Subtitles if (skipIndicatorDuration == 0L && currentItemPlayback.subtitleIndexEnabled) { val maxSize by animateFloatAsState(if (controllerViewState.controlsVisible) .7f else 1f) + val isImageSubtitles = remember(cues) { cues.firstOrNull()?.bitmap != null } AndroidView( factory = { context -> SubtitleView(context).apply { - preferences.appPreferences.interfacePreferences.subtitlesPreferences.let { + subtitleSettings.let { setStyle(it.toSubtitleStyle()) setFixedTextSize(Dimension.SP, it.fontSize.toFloat()) setBottomPaddingFraction(it.margin.toFloat() / 100f) @@ -416,10 +433,8 @@ fun PlaybackPageContent( }, update = { it.setCues(cues) - Media3SubtitleOverride( - preferences.appPreferences.interfacePreferences.subtitlesPreferences - .calculateEdgeSize(density), - ).apply(it) + Media3SubtitleOverride(subtitleSettings.calculateEdgeSize(density)) + .apply(it) }, onReset = { it.setCues(null) @@ -428,7 +443,8 @@ fun PlaybackPageContent( Modifier .fillMaxSize(maxSize) .align(Alignment.TopCenter) - .background(Color.Transparent), + .background(Color.Transparent) + .ifElse(isImageSubtitles, Modifier.alpha(subtitleImageOpacity)), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt index f84d3b89..c9f6f529 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferenceUtils.kt @@ -2,21 +2,20 @@ package com.github.damontecres.wholphin.ui.preferences import androidx.annotation.StringRes import com.github.damontecres.wholphin.preferences.AppPreference -import com.github.damontecres.wholphin.preferences.AppPreferences import kotlinx.serialization.Serializable /** * A group of preferences */ -data class PreferenceGroup( +data class PreferenceGroup( @param:StringRes val title: Int, - val preferences: List>, - val conditionalPreferences: List = listOf(), + val preferences: List>, + val conditionalPreferences: List> = listOf(), ) -data class ConditionalPreferences( - val condition: (AppPreferences) -> Boolean, - val preferences: List>, +data class ConditionalPreferences( + val condition: (T) -> Boolean, + val preferences: List>, ) /** @@ -34,8 +33,6 @@ sealed interface PreferenceValidation { enum class PreferenceScreenOption { BASIC, ADVANCED, - USER_INTERFACE, - SUBTITLES, EXO_PLAYER, MPV, ; diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index 8fedf3b6..dcbda2fc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -50,7 +50,6 @@ import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences -import com.github.damontecres.wholphin.preferences.uiPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.components.ConfirmDialog @@ -60,7 +59,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playOnClickSound import com.github.damontecres.wholphin.ui.playSoundOnFocus import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings -import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.UpdateViewModel import com.github.damontecres.wholphin.ui.setup.seerr.AddSeerServerDialog import com.github.damontecres.wholphin.ui.setup.seerr.SwitchSeerrViewModel @@ -125,8 +123,6 @@ fun PreferencesContent( when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> basicPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences - PreferenceScreenOption.USER_INTERFACE -> uiPreferences - PreferenceScreenOption.SUBTITLES -> SubtitleSettings.preferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences } @@ -134,8 +130,6 @@ fun PreferencesContent( when (preferenceScreenOption) { PreferenceScreenOption.BASIC -> R.string.settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings - PreferenceScreenOption.USER_INTERFACE -> R.string.ui_interface - PreferenceScreenOption.SUBTITLES -> R.string.subtitle_style PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options } @@ -526,7 +520,6 @@ fun PreferencesPage( when (preferenceScreenOption) { PreferenceScreenOption.BASIC, PreferenceScreenOption.ADVANCED, - PreferenceScreenOption.USER_INTERFACE, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, -> { @@ -539,12 +532,6 @@ fun PreferencesPage( .align(Alignment.TopEnd), ) } - - PreferenceScreenOption.SUBTITLES -> { - SubtitleStylePage( - initialPreferences, - ) - } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt new file mode 100644 index 00000000..8abc6dbe --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitlePreferencesContent.kt @@ -0,0 +1,195 @@ +package com.github.damontecres.wholphin.ui.preferences.subtitle + +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ClickPreference +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.preferences.PreferenceValidation +import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch + +@Composable +fun SubtitlePreferencesContent( + title: String, + preferences: SubtitlePreferences, + prefList: List>, + onPreferenceChange: suspend (SubtitlePreferences) -> Unit, + modifier: Modifier = Modifier, + viewModel: PreferencesViewModel = hiltViewModel(), + onFocus: (Int, Int) -> Unit = { _, _ -> }, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusRequester = remember { FocusRequester() } + var focusedIndex by rememberSaveable { mutableStateOf(Pair(0, 0)) } + val state = rememberLazyListState() + + var visible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + // Forces the animated to trigger + visible = true + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInHorizontally { it / 2 }, + exit = fadeOut() + slideOutHorizontally { it / 2 }, + modifier = modifier, + ) { + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + ) { + stickyHeader { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + } + prefList.forEachIndexed { groupIndex, group -> + item { + Text( + text = stringResource(group.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) + } + val groupPreferences = + group.preferences + + group.conditionalPreferences + .filter { it.condition.invoke(preferences) } + .map { it.preferences } + .flatten() + groupPreferences.forEachIndexed { prefIndex, pref -> + pref as AppPreference + item { + val interactionSource = remember { MutableInteractionSource() } + val focused = interactionSource.collectIsFocusedAsState().value + LaunchedEffect(focused) { + if (focused) { + focusedIndex = Pair(groupIndex, prefIndex) + onFocus.invoke(groupIndex, prefIndex) + } + } + when (pref) { + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + scope.launch(ExceptionHandler()) { + val newValue = + SubtitlePreferences + .newBuilder() + .apply { resetSubtitles() } + .build() + onPreferenceChange.invoke(newValue) + } + }, + interactionSource = interactionSource, + ) + } + + else -> { + val value = pref.getter.invoke(preferences) + ComposablePreference( + preference = pref, + value = value, + onNavigate = viewModel.navigationManager::navigateTo, + onValueChange = { newValue -> + val validation = pref.validate(newValue) + when (validation) { + is PreferenceValidation.Invalid -> { + // TODO? + Toast + .makeText( + context, + validation.message, + Toast.LENGTH_SHORT, + ).show() + } + + PreferenceValidation.Valid -> { + scope.launch(ExceptionHandler()) { + onPreferenceChange.invoke( + pref.setter( + preferences, + newValue, + ), + ) + } + } + } + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt index 506649d5..b7f3d764 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleSettings.kt @@ -2,6 +2,8 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle import android.content.res.Configuration import android.graphics.Typeface +import android.os.Build +import android.view.Display import androidx.annotation.OptIn import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb @@ -13,14 +15,14 @@ import androidx.media3.ui.CaptionStyleCompat import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppChoicePreference import com.github.damontecres.wholphin.preferences.AppClickablePreference -import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppDestinationPreference import com.github.damontecres.wholphin.preferences.AppSliderPreference import com.github.damontecres.wholphin.preferences.AppSwitchPreference import com.github.damontecres.wholphin.preferences.BackgroundStyle import com.github.damontecres.wholphin.preferences.EdgeStyle import com.github.damontecres.wholphin.preferences.SubtitlePreferences -import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.indexOfFirstOrNull +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup import com.github.damontecres.wholphin.util.mpv.MPVLib import com.github.damontecres.wholphin.util.mpv.setPropertyColor @@ -28,18 +30,17 @@ import timber.log.Timber object SubtitleSettings { val FontSize = - AppSliderPreference( + AppSliderPreference( title = R.string.font_size, defaultValue = 24, min = 8, max = 70, interval = 2, getter = { - it.interfacePreferences.subtitlesPreferences.fontSize - .toLong() + it.fontSize.toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontSize = value.toInt() } + prefs.update { fontSize = value.toInt() } }, summarizer = { value -> value?.toString() }, ) @@ -59,12 +60,12 @@ object SubtitleSettings { ) val FontColor = - AppChoicePreference( + AppChoicePreference( title = R.string.font_color, defaultValue = Color.White, - getter = { Color(it.interfacePreferences.subtitlesPreferences.fontColor) }, + getter = { Color(it.fontColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { fontColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -75,49 +76,49 @@ object SubtitleSettings { ) val FontBold = - AppSwitchPreference( + AppSwitchPreference( title = R.string.bold_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontBold }, + getter = { it.fontBold }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontBold = value } + prefs.update { fontBold = value } }, ) val FontItalic = - AppSwitchPreference( + AppSwitchPreference( title = R.string.italic_font, defaultValue = false, - getter = { it.interfacePreferences.subtitlesPreferences.fontItalic }, + getter = { it.fontItalic }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontItalic = value } + prefs.update { fontItalic = value } }, ) val FontOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.font_opacity, defaultValue = 100, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.fontOpacity + it.fontOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { fontOpacity = value.toInt() } + prefs.update { fontOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val EdgeStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_style, defaultValue = EdgeStyle.EDGE_SOLID, - getter = { it.interfacePreferences.subtitlesPreferences.edgeStyle }, + getter = { it.edgeStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeStyle = value } + prefs.update { edgeStyle = value } }, displayValues = R.array.subtitle_edge, indexToValue = { EdgeStyle.forNumber(it) }, @@ -125,12 +126,12 @@ object SubtitleSettings { ) val EdgeColor = - AppChoicePreference( + AppChoicePreference( title = R.string.edge_color, defaultValue = Color.Black, - getter = { Color(it.interfacePreferences.subtitlesPreferences.edgeColor) }, + getter = { Color(it.edgeColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { edgeColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -141,29 +142,29 @@ object SubtitleSettings { ) val EdgeThickness = - AppSliderPreference( + AppSliderPreference( title = R.string.edge_size, defaultValue = 4, min = 1, max = 32, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.edgeThickness + it.edgeThickness .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { edgeThickness = value.toInt() } + prefs.update { edgeThickness = value.toInt() } }, summarizer = { value -> value?.let { "${it / 2.0}" } }, ) val BackgroundColor = - AppChoicePreference( + AppChoicePreference( title = R.string.background_color, defaultValue = Color.Transparent, - getter = { Color(it.interfacePreferences.subtitlesPreferences.backgroundColor) }, + getter = { Color(it.backgroundColor) }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundColor = value.toArgb().and(0x00FFFFFF) } + prefs.update { backgroundColor = value.toArgb().and(0x00FFFFFF) } }, displayValues = R.array.font_colors, indexToValue = { colorList.getOrNull(it) ?: Color.White }, @@ -174,30 +175,30 @@ object SubtitleSettings { ) val BackgroundOpacity = - AppSliderPreference( + AppSliderPreference( title = R.string.background_opacity, defaultValue = 50, min = 10, max = 100, interval = 10, getter = { - it.interfacePreferences.subtitlesPreferences.backgroundOpacity + it.backgroundOpacity .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundOpacity = value.toInt() } + prefs.update { backgroundOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val BackgroundStylePref = - AppChoicePreference( + AppChoicePreference( title = R.string.background_style, defaultValue = BackgroundStyle.BG_NONE, - getter = { it.interfacePreferences.subtitlesPreferences.backgroundStyle }, + getter = { it.backgroundStyle }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { backgroundStyle = value } + prefs.update { backgroundStyle = value } }, displayValues = R.array.background_style, indexToValue = { BackgroundStyle.forNumber(it) }, @@ -205,29 +206,51 @@ object SubtitleSettings { ) val Margin = - AppSliderPreference( + AppSliderPreference( title = R.string.subtitle_margin, defaultValue = 8, min = 0, max = 100, interval = 1, getter = { - it.interfacePreferences.subtitlesPreferences.margin + it.margin .toLong() }, setter = { prefs, value -> - prefs.updateSubtitlePreferences { margin = value.toInt() } + prefs.update { margin = value.toInt() } + }, + summarizer = { value -> value?.let { "$it%" } }, + ) + + val ImageOpacity = + AppSliderPreference( + title = R.string.image_subtitle_opacity, + defaultValue = 100, + min = 10, + max = 100, + interval = 5, + getter = { + it.imageSubtitleOpacity.toLong() + }, + setter = { prefs, value -> + prefs.update { imageSubtitleOpacity = value.toInt() } }, summarizer = { value -> value?.let { "$it%" } }, ) val Reset = - AppClickablePreference( + AppClickablePreference( title = R.string.reset, getter = { }, setter = { prefs, _ -> prefs }, ) + val HdrSettings = + AppDestinationPreference( + title = R.string.hdr_subtitle_style, + destination = Destination.SubtitleSettings(true), + ) + val preferences = listOf( PreferenceGroup( @@ -264,11 +287,25 @@ object SubtitleSettings { preferences = listOf( Margin, + ImageOpacity, Reset, ), ), ) + val hdrPreferenceGroup = + listOf( + PreferenceGroup( + title = R.string.hdr, + preferences = + listOf( + HdrSettings, + ), + ), + ) + + fun shouldShowHdr(display: Display): Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && display.isHdr + private fun combine( color: Int, opacity: Int, @@ -361,3 +398,5 @@ object SubtitleSettings { MPVLib.setPropertyString("sub-border-style", borderStyle) } } + +inline fun SubtitlePreferences.update(block: SubtitlePreferences.Builder.() -> Unit): SubtitlePreferences = toBuilder().apply(block).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt index b0bc6426..52b3570a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/subtitle/SubtitleStylePage.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui.preferences.subtitle +import android.content.pm.ActivityInfo +import android.os.Build import androidx.annotation.Dimension import androidx.annotation.OptIn import androidx.compose.foundation.Image @@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -19,9 +22,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -30,20 +37,25 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.preferences.PreferencesContent +import com.github.damontecres.wholphin.preferences.SubtitlePreferences +import com.github.damontecres.wholphin.preferences.resetSubtitles +import com.github.damontecres.wholphin.preferences.updateInterfacePreferences +import com.github.damontecres.wholphin.ui.findActivity import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.calculateEdgeSize import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings.toSubtitleStyle import com.github.damontecres.wholphin.util.Media3SubtitleOverride +import timber.log.Timber @OptIn(UnstableApi::class) @Composable fun SubtitleStylePage( initialPreferences: AppPreferences, + hdrSettings: Boolean, modifier: Modifier = Modifier, viewModel: PreferencesViewModel = hiltViewModel(), ) { + val context = LocalContext.current val density = LocalDensity.current var preferences by remember { mutableStateOf(initialPreferences) } LaunchedEffect(Unit) { @@ -51,8 +63,27 @@ fun SubtitleStylePage( preferences = it } } - val prefs = preferences.interfacePreferences.subtitlesPreferences + val display = LocalView.current.display + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + DisposableEffect(context) { + if (hdrSettings) { + Timber.v("Switching color mode to HDR") + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_HDR + } + onDispose { + context.findActivity()?.window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT + } + } + } + val prefs = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + } var focusedOnMargin by remember { mutableStateOf(false) } + var focusedOnImageOpacity by remember { mutableStateOf(false) } Row( modifier = modifier, @@ -72,7 +103,7 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) - if (!focusedOnMargin) { + if (!focusedOnMargin && !focusedOnImageOpacity) { Column( verticalArrangement = Arrangement.SpaceBetween, modifier = @@ -109,7 +140,7 @@ fun SubtitleStylePage( ) } } - } else { + } else if (focusedOnMargin) { // Margin AndroidView( factory = { context -> @@ -129,17 +160,79 @@ fun SubtitleStylePage( Modifier .fillMaxSize(), ) + } else if (focusedOnImageOpacity) { + AndroidView( + factory = { context -> + SubtitleView(context) + }, + update = { + it.setStyle( + SubtitlePreferences + .newBuilder() + .apply { + resetSubtitles() + }.build() + .toSubtitleStyle(), + ) + it.setCues( + listOf( + Cue + .Builder() + .setText("ExoPlayer only:\nImage based subtitles can be dimmed.") + .build(), + ), + ) + }, + modifier = + Modifier + .fillMaxSize() + .alpha(prefs.imageSubtitleOpacity / 100f), + ) } } - PreferencesContent( - initialPreferences = preferences, - preferenceScreenOption = PreferenceScreenOption.SUBTITLES, + val display = LocalView.current.display + val prefList = + remember(hdrSettings, display) { + if (!hdrSettings && SubtitleSettings.shouldShowHdr(display)) { + // If not on HDR page and display is HDR capable, then show the HDR button + SubtitleSettings.preferences + SubtitleSettings.hdrPreferenceGroup + } else { + SubtitleSettings.preferences + } + } + SubtitlePreferencesContent( + title = + if (hdrSettings) { + stringResource(R.string.hdr_subtitle_style) + } else { + stringResource(R.string.subtitle_style) + }, + preferences = + if (hdrSettings) { + preferences.interfacePreferences.hdrSubtitlesPreferences + } else { + preferences.interfacePreferences.subtitlesPreferences + }, + prefList = prefList, + onPreferenceChange = { newSubtitlePrefs -> + viewModel.preferenceDataStore.updateData { + it.updateInterfacePreferences { + if (hdrSettings) { + hdrSubtitlesPreferences = newSubtitlePrefs + } else { + subtitlesPreferences = newSubtitlePrefs + } + } + } + }, onFocus = { groupIndex, prefIndex -> - - focusedOnMargin = - SubtitleSettings.preferences.getOrNull(groupIndex)?.preferences?.getOrNull( - prefIndex, - ) == SubtitleSettings.Margin + val focusedPref = + SubtitleSettings.preferences + .getOrNull(groupIndex) + ?.preferences + ?.getOrNull(prefIndex) + focusedOnMargin = focusedPref == SubtitleSettings.Margin + focusedOnImageOpacity = focusedPref == SubtitleSettings.ImageOpacity }, modifier = Modifier diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 3c7f38ed..eac223b0 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -127,6 +127,7 @@ message SubtitlePreferences{ bool font_italic = 10; int32 margin = 11; int32 edge_thickness = 12; + int32 image_subtitle_opacity = 13; } message LiveTvPreferences { @@ -152,6 +153,7 @@ message InterfacePreferences { SubtitlePreferences subtitles_preferences = 7; LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; + SubtitlePreferences hdr_subtitles_preferences = 10; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c83a3b63..c85ef303 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -462,6 +462,8 @@ Request in 4K AV1 software decoding MPV is now the default player except for HDR.\nYou can change this in settings. + HDR subtitle style + Image subtitle opacity Disabled From d9d5ab02e8c419290643063fd1b04b161f6de2c4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Feb 2026 20:04:46 -0500 Subject: [PATCH 2/3] Add support for viewing photos in photo albums (#703) ## Description Add support for viewing photos in photo albums ### Features - Browse and view albums & photos - View an album as a slideshow - Show overlay with photo details and controls - Rotate, zoom, & pan photos - Apply basic filters to photos or albums such as contrast, saturation, red/green/blue, etc - Setting to include video clips during slideshows ### Controls - D-Pad left or right: scroll between photos in album - Hold D-Pad up or down: zoom in or out of the current photo - D-Pad left/right/up/down, while zoomed: pan around photo - Back, while zoomed: revert the zoom - Otherwise: show photo overlay ### Related issues Closes #458 Closes #634 ### Screenshots #### Overlay ![photo_overlay Large](https://github.com/user-attachments/assets/b44ed876-d8bd-4f75-aa1e-4106c2edb2f7) #### Applying filters ![photo_filters Large](https://github.com/user-attachments/assets/1379978a-b27c-47ac-80af-f7bb6a2177df) --- app/build.gradle.kts | 1 + .../30.json | 635 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 8 +- .../wholphin/data/PlaybackEffectDao.kt | 22 + .../wholphin/data/model/BaseItem.kt | 8 +- .../wholphin/data/model/PlaybackEffect.kt | 14 + .../wholphin/data/model/VideoFilter.kt | 223 ++++++ .../wholphin/preferences/AppPreference.kt | 42 ++ .../preferences/AppPreferencesSerializer.kt | 13 + .../wholphin/services/AppUpgradeHandler.kt | 9 + .../services/PlaybackLifecycleObserver.kt | 5 +- .../wholphin/services/hilt/DatabaseModule.kt | 5 + .../damontecres/wholphin/ui/UiConstants.kt | 9 + .../ui/components/CollectionFolderGrid.kt | 48 +- .../wholphin/ui/components/PlayButtons.kt | 75 ++- .../wholphin/ui/components/SliderBar.kt | 27 +- .../ui/detail/CollectionFolderGeneric.kt | 4 +- .../ui/detail/CollectionFolderPhotoAlbum.kt | 88 +++ .../wholphin/ui/nav/Destination.kt | 10 + .../wholphin/ui/nav/DestinationContent.kt | 18 + .../ui/preferences/SliderPreference.kt | 1 - .../ui/slideshow/ImageControlsOverlay.kt | 219 ++++++ .../ui/slideshow/ImageDetailsHeader.kt | 114 ++++ .../ui/slideshow/ImageFilterSliders.kt | 296 ++++++++ .../ui/slideshow/ImageLoadingPlaceholder.kt | 54 ++ .../wholphin/ui/slideshow/ImageOverlay.kt | 146 ++++ .../wholphin/ui/slideshow/SlideshowPage.kt | 509 ++++++++++++++ .../ui/slideshow/SlideshowViewModel.kt | 445 ++++++++++++ .../wholphin/ui/util/ThrottledLiveData.kt | 82 +++ app/src/main/proto/WholphinDataStore.proto | 6 + app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 22 +- gradle/libs.versions.toml | 1 + 33 files changed, 3128 insertions(+), 32 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8b3f74ef..37729e19 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -265,6 +265,7 @@ dependencies { implementation(libs.androidx.preference.ktx) implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) + implementation(libs.androidx.media3.effect) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json new file mode 100644 index 00000000..715fb618 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/30.json @@ -0,0 +1,635 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "f9b86b73e972aa002238d43ab2c8dcc2", + "entities": [ + { + "tableName": "servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `url` TEXT NOT NULL, `version` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `name` TEXT, `serverId` TEXT NOT NULL, `accessToken` TEXT, `pin` TEXT, FOREIGN KEY(`serverId`) REFERENCES `servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT" + }, + { + "fieldPath": "pin", + "columnName": "pin", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_users_id_serverId", + "unique": true, + "columnNames": [ + "id", + "serverId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_users_id_serverId` ON `${TABLE_NAME}` (`id`, `serverId`)" + }, + { + "name": "index_users_id", + "unique": false, + "columnNames": [ + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_id` ON `${TABLE_NAME}` (`id`)" + }, + { + "name": "index_users_serverId", + "unique": false, + "columnNames": [ + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_users_serverId` ON `${TABLE_NAME}` (`serverId`)" + } + ], + "foreignKeys": [ + { + "table": "servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ItemPlayback", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`rowId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sourceId` TEXT, `audioIndex` INTEGER NOT NULL, `subtitleIndex` INTEGER NOT NULL, FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "rowId", + "columnName": "rowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sourceId", + "columnName": "sourceId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioIndex", + "columnName": "audioIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subtitleIndex", + "columnName": "subtitleIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "rowId" + ] + }, + "indices": [ + { + "name": "index_ItemPlayback_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ItemPlayback_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "NavDrawerPinnedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "LibraryDisplayInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `sort` TEXT NOT NULL, `direction` TEXT NOT NULL, `filter` TEXT NOT NULL DEFAULT '{}', `viewOptions` TEXT, PRIMARY KEY(`userId`, `itemId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'{}'" + }, + { + "fieldPath": "viewOptions", + "columnName": "viewOptions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId" + ] + }, + "indices": [ + { + "name": "index_LibraryDisplayInfo_userId_itemId", + "unique": true, + "columnNames": [ + "userId", + "itemId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_LibraryDisplayInfo_userId_itemId` ON `${TABLE_NAME}` (`userId`, `itemId`)" + } + ], + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "playback_effects", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `type` TEXT NOT NULL, `rotation` INTEGER NOT NULL, `brightness` INTEGER NOT NULL, `contrast` INTEGER NOT NULL, `saturation` INTEGER NOT NULL, `hue` INTEGER NOT NULL, `red` INTEGER NOT NULL, `green` INTEGER NOT NULL, `blue` INTEGER NOT NULL, `blur` INTEGER NOT NULL, PRIMARY KEY(`jellyfinUserRowId`, `itemId`, `type`))", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoFilter.rotation", + "columnName": "rotation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.brightness", + "columnName": "brightness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.contrast", + "columnName": "contrast", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.saturation", + "columnName": "saturation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.hue", + "columnName": "hue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.red", + "columnName": "red", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.green", + "columnName": "green", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blue", + "columnName": "blue", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoFilter.blur", + "columnName": "blur", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "itemId", + "type" + ] + } + }, + { + "tableName": "PlaybackLanguageChoice", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `seriesId` TEXT NOT NULL, `itemId` TEXT, `audioLanguage` TEXT, `subtitleLanguage` TEXT, `subtitlesDisabled` INTEGER, PRIMARY KEY(`userId`, `seriesId`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "seriesId", + "columnName": "seriesId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT" + }, + { + "fieldPath": "audioLanguage", + "columnName": "audioLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitleLanguage", + "columnName": "subtitleLanguage", + "affinity": "TEXT" + }, + { + "fieldPath": "subtitlesDisabled", + "columnName": "subtitlesDisabled", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "seriesId" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "ItemTrackModification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` INTEGER NOT NULL, `itemId` TEXT NOT NULL, `trackIndex` INTEGER NOT NULL, `delayMs` INTEGER NOT NULL, PRIMARY KEY(`userId`, `itemId`, `trackIndex`), FOREIGN KEY(`userId`) REFERENCES `users`(`rowId`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "userId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "itemId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "trackIndex", + "columnName": "trackIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "delayMs", + "columnName": "delayMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "userId", + "itemId", + "trackIndex" + ] + }, + "foreignKeys": [ + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "userId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + }, + { + "tableName": "seerr_servers", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT NOT NULL, `name` TEXT, `version` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_seerr_servers_url", + "unique": true, + "columnNames": [ + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_seerr_servers_url` ON `${TABLE_NAME}` (`url`)" + } + ] + }, + { + "tableName": "seerr_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`jellyfinUserRowId` INTEGER NOT NULL, `serverId` INTEGER NOT NULL, `authMethod` TEXT NOT NULL, `username` TEXT, `password` TEXT, `credential` TEXT, PRIMARY KEY(`jellyfinUserRowId`, `serverId`), FOREIGN KEY(`serverId`) REFERENCES `seerr_servers`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`jellyfinUserRowId`) REFERENCES `users`(`rowId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "jellyfinUserRowId", + "columnName": "jellyfinUserRowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authMethod", + "columnName": "authMethod", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT" + }, + { + "fieldPath": "credential", + "columnName": "credential", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "jellyfinUserRowId", + "serverId" + ] + }, + "foreignKeys": [ + { + "table": "seerr_servers", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "serverId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "users", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "jellyfinUserRowId" + ], + "referencedColumns": [ + "rowId" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f9b86b73e972aa002238d43ab2c8dcc2')" + ] + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt index 19e33616..a3629843 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/AppDatabase.kt @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.PlaybackEffect import com.github.damontecres.wholphin.data.model.PlaybackLanguageChoice import com.github.damontecres.wholphin.data.model.SeerrServer import com.github.damontecres.wholphin.data.model.SeerrUser @@ -32,12 +33,14 @@ import java.util.UUID ItemPlayback::class, NavDrawerPinnedItem::class, LibraryDisplayInfo::class, + PlaybackEffect::class, PlaybackLanguageChoice::class, ItemTrackModification::class, SeerrServer::class, SeerrUser::class, + ], - version = 20, + version = 30, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -50,6 +53,7 @@ import java.util.UUID AutoMigration(10, 11), AutoMigration(11, 12), AutoMigration(12, 20), + AutoMigration(20, 30), ], ) @TypeConverters(Converters::class) @@ -65,6 +69,8 @@ abstract class AppDatabase : RoomDatabase() { abstract fun playbackLanguageChoiceDao(): PlaybackLanguageChoiceDao abstract fun seerrServerDao(): SeerrServerDao + + abstract fun playbackEffectDao(): PlaybackEffectDao } class Converters { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt new file mode 100644 index 00000000..0fa9f0ee --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/PlaybackEffectDao.kt @@ -0,0 +1,22 @@ +package com.github.damontecres.wholphin.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.damontecres.wholphin.data.model.PlaybackEffect +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +@Dao +interface PlaybackEffectDao { + @Query("SELECT * FROM playback_effects WHERE jellyfinUserRowId=:jellyfinUserRowId AND itemId=:itemId AND type=:type") + suspend fun getPlaybackEffect( + jellyfinUserRowId: Int, + itemId: UUID, + type: BaseItemKind, + ): PlaybackEffect? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(playbackEffect: PlaybackEffect) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index a9673131..869a0398 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -106,6 +106,12 @@ data class BaseItem( data.premiereDate?.let { add(DateFormatter.format(it)) } } else if (type == BaseItemKind.SERIES) { data.seriesProductionYears?.let(::add) + } else if (type == BaseItemKind.PHOTO) { + if (data.productionYear != null) { + add(data.productionYear!!.toString()) + } else if (data.premiereDate != null) { + add(data.premiereDate!!.toLocalDate().toString()) + } } else { data.productionYear?.let { add(it.toString()) } } @@ -154,7 +160,7 @@ data class BaseItem( it.dayOfMonth.toString().padStart(2, '0') }?.toIntOrNull() - fun destination(): Destination { + fun destination(index: Int? = null): Destination { val result = // Redirect episodes & seasons to their series if possible when (type) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt new file mode 100644 index 00000000..3c5cf6a0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/PlaybackEffect.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.room.Embedded +import androidx.room.Entity +import org.jellyfin.sdk.model.api.BaseItemKind +import java.util.UUID + +@Entity(tableName = "playback_effects", primaryKeys = ["jellyfinUserRowId", "itemId", "type"]) +data class PlaybackEffect( + val jellyfinUserRowId: Int, + val itemId: UUID, + val type: BaseItemKind, + @Embedded val videoFilter: VideoFilter, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt new file mode 100644 index 00000000..e71db95c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/VideoFilter.kt @@ -0,0 +1,223 @@ +package com.github.damontecres.wholphin.data.model + +import android.graphics.ColorMatrix +import androidx.annotation.IntRange +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.effect.Brightness +import androidx.media3.effect.Contrast +import androidx.media3.effect.GaussianBlur +import androidx.media3.effect.GlEffect +import androidx.media3.effect.HslAdjustment +import androidx.media3.effect.RgbAdjustment +import androidx.media3.effect.ScaleAndRotateTransformation +import androidx.room.Ignore + +/** + * Modifications to a video playback + */ +data class VideoFilter( + val rotation: Int = 0, + @param:IntRange(0, 200) val brightness: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val contrast: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val saturation: Int = COLOR_DEFAULT, + @param:IntRange(0, 360) val hue: Int = HUE_DEFAULT, + @param:IntRange(0, 200) val red: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val green: Int = COLOR_DEFAULT, + @param:IntRange(0, 200) val blue: Int = COLOR_DEFAULT, + @param:IntRange(0, 250) val blur: Int = 0, +) { + @Ignore + val colorMatrix: androidx.compose.ui.graphics.ColorMatrix = createComposeColorMatrix() + + companion object { + const val COLOR_DEFAULT = 100 + const val HUE_DEFAULT = 0 + } + + fun isRotated(): Boolean = rotation != 0 && rotation % 360 != 0 + + fun hasRgb(): Boolean = red != COLOR_DEFAULT || green != COLOR_DEFAULT || blue != COLOR_DEFAULT + + fun hasBrightness(): Boolean = brightness != COLOR_DEFAULT + + fun hasContrast(): Boolean = contrast != COLOR_DEFAULT + + fun hasHsl(): Boolean = hue != HUE_DEFAULT || saturation != COLOR_DEFAULT + + fun hasBlur(): Boolean = blur > 0 + + fun hasImageFilter(): Boolean = hasRgb() || hasBrightness() || hasContrast() || saturation != COLOR_DEFAULT + + @OptIn(UnstableApi::class) + private fun rgbAdjustment(): RgbAdjustment = + RgbAdjustment + .Builder() + .setRedScale(red / COLOR_DEFAULT.toFloat()) + .setGreenScale(green / COLOR_DEFAULT.toFloat()) + .setBlueScale(blue / COLOR_DEFAULT.toFloat()) + .build() + + /** + * Create the list of effects to apply + */ + @OptIn(UnstableApi::class) + fun createEffectList(): List = + buildList { + if (isRotated()) { + add( + ScaleAndRotateTransformation + .Builder() + .setRotationDegrees(rotation.toFloat()) + .build(), + ) + } + if (hasRgb()) { + add(rgbAdjustment()) + } + if (hasBrightness()) { + add(Brightness((brightness - 100) / 100f)) + } + if (hasContrast()) { + add(Contrast((contrast - 100) / 100f)) + } + if (hasHsl()) { + add( + HslAdjustment + .Builder() + .adjustHue(hue.toFloat()) + .adjustSaturation((saturation - 100).toFloat()) + .build(), + ) + } + if (hasBlur()) { + add(GaussianBlur(blur / 10f)) + } + } + + private fun saturationMatrix(): FloatArray { + val rF = 0.2999f + val gF = 0.587f + val bF = 0.114f + val s = saturation / 100.0f + + val ms = 1.0f - s + val rT = rF * ms + val gT = gF * ms + val bT = bF * ms + + val m = + FloatArray(20) { + when (it) { + 0 -> (rT + s) + 1 -> gT + 2 -> bT + 5 -> rT + 6 -> (gT + s) + 7 -> bT + 10 -> rT + 11 -> gT + 12 -> (bT + s) + 18 -> 1f + else -> 0f + } + } + return m + } + + @OptIn(UnstableApi::class) + fun createColorMatrix(): ColorMatrix { + val matrix = ColorMatrix() + val tempMatrix = ColorMatrix() + + if (saturation != COLOR_DEFAULT) { + matrix.set(saturationMatrix()) + } + if (hasRgb()) { + val colorMatrix = rgbAdjustment().getMatrix(0L, false) + val m = FloatArray(20) + m[0] = colorMatrix[0] + m[1] = colorMatrix[1] + m[2] = colorMatrix[2] + m[3] = colorMatrix[3] + m[4] = 0f + m[5] = colorMatrix[4] + m[6] = colorMatrix[5] + m[7] = colorMatrix[6] + m[8] = colorMatrix[7] + m[9] = 0f + m[10] = colorMatrix[8] + m[11] = colorMatrix[9] + m[12] = colorMatrix[10] + m[13] = colorMatrix[11] + m[14] = 0f + m[15] = colorMatrix[12] + m[16] = colorMatrix[13] + m[17] = colorMatrix[14] + m[18] = colorMatrix[15] + m[19] = 0f + tempMatrix.set(m) + matrix.postConcat(tempMatrix) + } + if (hasContrast()) { + val scale = contrast / 100.0f + tempMatrix.setScale(scale, scale, scale, 1f) + matrix.postConcat(tempMatrix) + } + if (hasBrightness()) { + val b = brightness / 100.0f + val m = FloatArray(20) + m[0] = b + m[6] = b + m[12] = b + m[18] = 1f + tempMatrix.set(m) + matrix.postConcat(tempMatrix) + } + // TODO hue + // TODO blur + return matrix + } + + @OptIn(UnstableApi::class) + fun createComposeColorMatrix(): androidx.compose.ui.graphics.ColorMatrix { + val matrix = + androidx.compose.ui.graphics + .ColorMatrix() + + if (saturation != COLOR_DEFAULT) { + matrix.setToSaturation(saturation / 100f) + } + if (hasRgb()) { + matrix.setToScale( + redScale = red / 100f, + greenScale = green / 100f, + blueScale = blue / 100f, + alphaScale = 1f, + ) + } + if (hasContrast()) { + val scale = contrast / 100.0f + val tempMatrix = + androidx.compose.ui.graphics + .ColorMatrix() + tempMatrix.setToScale(scale, scale, scale, 1f) + matrix.timesAssign(tempMatrix) + } + if (hasBrightness()) { + val b = brightness / 100.0f + val m = FloatArray(20) + m[0] = b + m[6] = b + m[12] = b + m[18] = 1f + val tempMatrix = + androidx.compose.ui.graphics + .ColorMatrix(m) + matrix.timesAssign(tempMatrix) + } + // TODO hue + // TODO blur + return matrix + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 0472be5a..6015a128 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -903,6 +903,46 @@ sealed interface AppPreference { getter = { }, setter = { prefs, _ -> prefs }, ) + + val SlideshowDuration = + AppSliderPreference( + title = R.string.slideshow_duration, + defaultValue = 5_000, + min = 1_000, + max = 30.seconds.inWholeMilliseconds, + interval = 250, + getter = { + it.photoPreferences.slideshowDuration + }, + setter = { prefs, value -> + prefs.updatePhotoPreferences { + slideshowDuration = value + } + }, + summarizer = { value -> + if (value != null) { + val seconds = value / 1000.0 + WholphinApplication.instance.resources.getString( + R.string.decimal_seconds, + seconds, + ) + } else { + null + } + }, + ) + + val SlideshowPlayVideos = + AppSwitchPreference( + title = R.string.play_videos_during_slideshow, + defaultValue = false, + getter = { it.photoPreferences.slideshowPlayVideos }, + setter = { prefs, value -> + prefs.updatePhotoPreferences { slideshowPlayVideos = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) } } @@ -1015,6 +1055,8 @@ val advancedPreferences = // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, AppPreference.BackdropStylePref, + AppPreference.SlideshowDuration, + AppPreference.SlideshowPlayVideos, ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt index ca343804..e2182aa3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreferencesSerializer.kt @@ -128,6 +128,14 @@ class AppPreferencesSerializer imageDiskCacheSizeBytes = AppPreference.ImageDiskCacheSize.defaultValue * AppPreference.MEGA_BIT }.build() + + photoPreferences = + PhotoPreferences + .newBuilder() + .apply { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + slideshowPlayVideos = AppPreference.SlideshowPlayVideos.defaultValue + }.build() }.build() override suspend fun readFrom(input: InputStream): AppPreferences { @@ -186,6 +194,11 @@ inline fun AppPreferences.updateAdvancedPreferences(block: AdvancedPreferences.B advancedPreferences = advancedPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder.() -> Unit): AppPreferences = + update { + photoPreferences = photoPreferences.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt index 7e911767..96130f48 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/AppUpgradeHandler.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions +import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel @@ -228,4 +229,12 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-7-g0"))) { + appPreferences.updateData { + it.updatePhotoPreferences { + slideshowDuration = AppPreference.SlideshowDuration.defaultValue + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 3c32a497..4be082a5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -21,7 +21,10 @@ class PlaybackLifecycleObserver override fun onStart(owner: LifecycleOwner) { val lastDest = navigationManager.backStack.lastOrNull() - if (lastDest is Destination.Playback || lastDest is Destination.PlaybackList) { + if (lastDest is Destination.Playback || + lastDest is Destination.PlaybackList || + lastDest is Destination.Slideshow + ) { navigationManager.goBack() } wasPlaying = null diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt index ed3f761b..31c92774 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/DatabaseModule.kt @@ -11,6 +11,7 @@ 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 @@ -66,6 +67,10 @@ object DatabaseModule { @Singleton fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + @Provides @Singleton fun userPreferencesDataStore( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 54f564f1..958f9dfd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -57,6 +57,7 @@ val DefaultItemFields = ItemFields.CHAPTERS, ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, ) /** @@ -70,8 +71,16 @@ val SlimItemFields = ItemFields.OVERVIEW, ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, + ItemFields.PARENT_ID, ) +val PhotoItemFields = + DefaultItemFields + + listOf( + ItemFields.WIDTH, + ItemFields.HEIGHT, + ) + object Cards { val height2x3 = 172.dp val heightEpisode = height2x3 * .75f diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt index 29991126..89cda77e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderGrid.kt @@ -577,21 +577,45 @@ fun CollectionFolderGrid( onSaveViewOptions = { viewModel.saveViewOptions(it) }, onChangeBackdrop = viewModel::updateBackdrop, playEnabled = playEnabled, - onClickPlay = { _, item -> - viewModel.navigationManager.navigateTo(Destination.Playback(item)) + onClickPlay = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = item.id, + index = index, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.Playback(item) + } + viewModel.navigationManager.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { - viewModel.navigationManager.navigateTo( - Destination.PlaybackList( - itemId = it, - startIndex = 0, - shuffle = shuffle, - recursive = recursive, - sortAndDirection = sortAndDirection, - filter = filter, - ), - ) + val destination = + if (item?.type == BaseItemKind.PHOTO_ALBUM) { + Destination.Slideshow( + parentId = it, + index = 0, + filter = CollectionFolderFilter(filter = filter), + sortAndDirection = sortAndDirection, + recursive = true, + startSlideshow = true, + ) + } else { + Destination.PlaybackList( + itemId = it, + startIndex = 0, + shuffle = shuffle, + recursive = recursive, + sortAndDirection = sortAndDirection, + filter = filter, + ) + } + viewModel.navigationManager.navigateTo(destination) } }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt index 2856f319..17b84dee 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/PlayButtons.kt @@ -34,7 +34,9 @@ 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.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -180,6 +182,63 @@ fun ExpandablePlayButton( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, mirrorIcon: Boolean = false, enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + imageVector = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: Painter, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + mirrorIcon: Boolean = false, + enabled: Boolean = true, +) = ExpandablePlayButton( + title = title, + resume = resume, + icon = { + Icon( + painter = icon, + contentDescription = null, + modifier = + Modifier + .size(28.dp) + .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), + ) + }, + onClick = onClick, + modifier = modifier, + interactionSource = interactionSource, + enabled = enabled, +) + +@Composable +fun ExpandablePlayButton( + @StringRes title: Int, + resume: Duration, + icon: @Composable () -> Unit, + onClick: (position: Duration) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + enabled: Boolean = true, ) { val isFocused = interactionSource.collectIsFocusedAsState().value Button( @@ -200,14 +259,7 @@ fun ExpandablePlayButton( .padding(start = 2.dp, top = 2.dp) .height(MinButtonSize), ) { - Icon( - imageVector = icon, - contentDescription = null, - modifier = - Modifier - .size(28.dp) - .ifElse(mirrorIcon, Modifier.graphicsLayer { scaleX = -1f }), - ) + icon.invoke() } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -343,6 +395,13 @@ private fun ViewOptionsPreview() { onClick = {}, interactionSource = source, ) + ExpandablePlayButton( + title = R.string.play, + resume = Duration.ZERO, + icon = painterResource(R.drawable.baseline_pause_24), + onClick = {}, + interactionSource = source, + ) ExpandableFaButton( title = R.string.play, iconStringRes = R.string.fa_eye, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index d9d69f6a..64cfc78b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -40,7 +40,7 @@ fun SliderBar( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interval: Int = 1, - color: Color = MaterialTheme.colorScheme.border, + colors: SliderColors = SliderColors.default(), ) { val isFocused by interactionSource.collectIsFocusedAsState() val animatedIndicatorHeight by animateDpAsState( @@ -49,9 +49,6 @@ fun SliderBar( var currentValue by remember(value) { mutableLongStateOf(value) } val percent = (currentValue - min).toFloat() / (max - min) - val activeColor = SliderActiveColor(isFocused) - val inactiveColor = SliderInactiveColor(isFocused) - val handleSeekEventModifier = Modifier.handleDPadKeyEvents( triggerOnAction = KeyEvent.ACTION_DOWN, @@ -91,14 +88,14 @@ fun SliderBar( onDraw = { val yOffset = size.height.div(2) drawLine( - color = inactiveColor, + color = if (isFocused) colors.inactiveFocused else colors.inactiveUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset(x = size.width, y = yOffset), strokeWidth = size.height, cap = StrokeCap.Round, ) drawLine( - color = activeColor, + color = if (isFocused) colors.activeFocused else colors.activeUnfocused, start = Offset(x = 0f, y = yOffset), end = Offset( @@ -119,6 +116,24 @@ fun SliderBar( } } +data class SliderColors( + val activeFocused: Color, + val activeUnfocused: Color, + val inactiveFocused: Color, + val inactiveUnfocused: Color, +) { + companion object { + @Composable + fun default() = + SliderColors( + activeFocused = SliderActiveColor(true), + activeUnfocused = SliderActiveColor(false), + inactiveFocused = SliderInactiveColor(true), + inactiveUnfocused = SliderInactiveColor(false), + ) + } +} + @Composable fun SliderActiveColor(focused: Boolean): Color { val theme = LocalTheme.current diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt index 6db471e3..800c84c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderGeneric.kt @@ -45,7 +45,9 @@ fun CollectionFolderGeneric( } CollectionFolderGrid( preferences = preferences, - onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, + onClickItem = { index, item -> + preferencesViewModel.navigationManager.navigateTo(item.destination(index)) + }, itemId = itemId, initialFilter = filter, showTitle = showHeader, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt new file mode 100644 index 00000000..c36da909 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPhotoAlbum.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.ui.detail + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.data.filter.DefaultFilterOptions +import com.github.damontecres.wholphin.data.filter.ItemFilterBy +import com.github.damontecres.wholphin.data.model.CollectionFolderFilter +import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid +import com.github.damontecres.wholphin.ui.components.CollectionFolderViewModel +import com.github.damontecres.wholphin.ui.components.ViewOptionsWide +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import com.github.damontecres.wholphin.ui.data.VideoSortOptions +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.toServerString +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import java.util.UUID + +@Composable +fun CollectionFolderPhotoAlbum( + preferences: UserPreferences, + itemId: UUID, + recursive: Boolean, + modifier: Modifier = Modifier, + filter: CollectionFolderFilter = CollectionFolderFilter(), + filterOptions: List> = DefaultFilterOptions, + sortOptions: List = VideoSortOptions, + // Note: making the VM here and passing down is bad practice, but we need the grid state when clicking on items + // TODO refactor this + viewModel: CollectionFolderViewModel = + hiltViewModel( + key = itemId.toServerString(), + ) { + it.create( + itemId = itemId.toServerString(), + initialSortAndDirection = null, + recursive = recursive, + collectionFilter = filter, + useSeriesForPrimary = false, + defaultViewOptions = ViewOptionsWide, + ) + }, +) { + var showHeader by remember { mutableStateOf(true) } + CollectionFolderGrid( + preferences = preferences, + onClickItem = { index, item -> + val destination = + if (item.type == BaseItemKind.PHOTO) { + Destination.Slideshow( + parentId = itemId, + index = index, + filter = CollectionFolderFilter(filter = viewModel.filter.value ?: GetItemsFilter()), + sortAndDirection = viewModel.sortAndDirection.value ?: SortAndDirection.DEFAULT, + recursive = true, + startSlideshow = false, + ) + } else { + item.destination(index) + } + viewModel.navigationManager.navigateTo(destination) + }, + itemId = itemId.toServerString(), + initialFilter = filter, + showTitle = showHeader, + recursive = recursive, + sortOptions = sortOptions, + modifier = + modifier + .padding(start = 16.dp), + positionCallback = { columns, position -> + showHeader = position < columns + }, + defaultViewOptions = ViewOptionsWide, + playEnabled = true, + filterOptions = filterOptions, + viewModel = viewModel, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index e6f3636d..0d16f36a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -105,6 +105,16 @@ sealed class Destination( val itemIds: List, ) : Destination(false) + @Serializable + data class Slideshow( + val parentId: UUID, + val index: Int, + val filter: CollectionFolderFilter, + val sortAndDirection: SortAndDirection, + val recursive: Boolean, + val startSlideshow: Boolean, + ) : Destination(true) + @Serializable data object Favorites : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index 09fd3936..c1e70880 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.detail.CollectionFolderBoxSet import com.github.damontecres.wholphin.ui.detail.CollectionFolderGeneric import com.github.damontecres.wholphin.ui.detail.CollectionFolderLiveTv import com.github.damontecres.wholphin.ui.detail.CollectionFolderMovie +import com.github.damontecres.wholphin.ui.detail.CollectionFolderPhotoAlbum import com.github.damontecres.wholphin.ui.detail.CollectionFolderPlaylist import com.github.damontecres.wholphin.ui.detail.CollectionFolderRecordings import com.github.damontecres.wholphin.ui.detail.CollectionFolderTv @@ -36,6 +37,7 @@ import com.github.damontecres.wholphin.ui.playback.PlaybackPage import com.github.damontecres.wholphin.ui.preferences.PreferencesPage import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleStylePage import com.github.damontecres.wholphin.ui.setup.InstallUpdatePage +import com.github.damontecres.wholphin.ui.slideshow.SlideshowPage import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import timber.log.Timber @@ -195,6 +197,16 @@ fun DestinationContent( ) } + BaseItemKind.PHOTO_ALBUM -> { + LaunchedEffect(Unit) { onClearBackdrop.invoke() } + CollectionFolderPhotoAlbum( + preferences = preferences, + itemId = destination.itemId, + recursive = true, + modifier = modifier, + ) + } + else -> { Timber.w("Unsupported item type: ${destination.type}") Text("Unsupported item type: ${destination.type}") @@ -234,6 +246,12 @@ fun DestinationContent( ) } + is Destination.Slideshow -> { + SlideshowPage( + slideshow = destination, + ) + } + Destination.Favorites -> { LaunchedEffect(Unit) { onClearBackdrop.invoke() } FavoritesPage( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt index 56c15f7f..e5564ee2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SliderPreference.kt @@ -77,7 +77,6 @@ fun SliderPreference( max = preference.max, interval = preference.interval, onChange = onChange, - color = MaterialTheme.colorScheme.border, enableWrapAround = false, interactionSource = interactionSource, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt new file mode 100644 index 00000000..1b9555c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageControlsOverlay.kt @@ -0,0 +1,219 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.annotation.OptIn +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Icon +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@OptIn(UnstableApi::class) +@Composable +fun ImageControlsOverlay( + slideshowEnabled: Boolean, + slideshowControls: SlideshowControls, + onDismiss: () -> Unit, + isImageClip: Boolean, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + moreOnClick: () -> Unit, + isPlaying: Boolean, + playPauseOnClick: () -> Unit, + onShowFilterDialogClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester?, + modifier: Modifier = Modifier, +) { + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + val onFocused = { focusState: FocusState -> + if (focusState.isFocused && bringIntoViewRequester != null) { + scope.launch(ExceptionHandler()) { bringIntoViewRequester.bringIntoView() } + } + } + + LazyRow( + modifier = + modifier + .focusGroup(), + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + item { + ExpandablePlayButton( + title = if (slideshowEnabled) R.string.stop_slideshow else R.string.play_slideshow, + icon = + painterResource( + if (slideshowEnabled) { + R.drawable.baseline_pause_24 + } else { + R.drawable.baseline_play_arrow_24 + }, + ), + resume = Duration.ZERO, + onClick = { + if (slideshowEnabled) { + slideshowControls.stopSlideshow() + } else { + slideshowControls.startSlideshow() + onDismiss.invoke() + } + }, + modifier = + Modifier + .focusRequester(focusRequester) + .onFocusChanged(onFocused), + ) + } + if (isImageClip) { + item { + Button( + onClick = playPauseOnClick, + modifier = + Modifier + .onFocusChanged(onFocused), + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + ) { + Icon( + painter = + painterResource( + if (isPlaying) R.drawable.baseline_play_arrow_24 else R.drawable.baseline_pause_24, + ), + contentDescription = null, + ) + } + } + } else { + // Regular image + item { + ExpandableFaButton( + title = R.string.rotate_left, + iconStringRes = R.string.fa_rotate_left, + onClick = { onRotate(-90) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.rotate_right, + iconStringRes = R.string.fa_rotate_right, + onClick = { onRotate(90) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.zoom_in, + iconStringRes = R.string.fa_magnifying_glass_plus, + onClick = { onZoom(.15f) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.zoom_out, + iconStringRes = R.string.fa_magnifying_glass_minus, + onClick = { onZoom(-.15f) }, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.reset, + iconStringRes = R.string.fa_arrows_rotate, + onClick = onReset, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + item { + ExpandableFaButton( + title = R.string.filter, + iconStringRes = R.string.fa_sliders, + onClick = onShowFilterDialogClick, + modifier = + Modifier + .onFocusChanged(onFocused), + ) + } + } + // More button +// item { +// ExpandablePlayButton( +// title = R.string.more, +// resume = Duration.ZERO, +// icon = Icons.Default.MoreVert, +// onClick = { moreOnClick.invoke() }, +// modifier = Modifier.onFocusChanged(onFocused), +// ) +// } + } +} + +@Preview(widthDp = 800) +@Composable +private fun ImageControlsOverlayPreview() { + WholphinTheme { + ImageControlsOverlay( + slideshowEnabled = true, + slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + } + + override fun stopSlideshow() { + } + }, + isImageClip = false, + onZoom = {}, + onRotate = {}, + onReset = {}, + moreOnClick = {}, + isPlaying = false, + playPauseOnClick = {}, + bringIntoViewRequester = null, + onShowFilterDialogClick = {}, + onDismiss = {}, + modifier = Modifier, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt new file mode 100644 index 00000000..99675dc0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageDetailsHeader.kt @@ -0,0 +1,114 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.StreamLabel +import com.github.damontecres.wholphin.ui.components.VideoStreamDetails +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import org.jellyfin.sdk.model.api.MediaType + +@OptIn(UnstableApi::class) +@Composable +fun ImageDetailsHeader( + slideshowEnabled: Boolean, + slideshowControls: SlideshowControls, + player: Player, + image: ImageState, + position: Int, + count: Int, + moreOnClick: () -> Unit, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + onShowFilterDialogClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val scope = rememberCoroutineScope() + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .bringIntoViewRequester(bringIntoViewRequester), + ) { + image.image.title?.let { + Text( + text = it, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + } + if (image.image.ui.quickDetails + .isNotNullOrBlank() + ) { + QuickDetails(image.image.ui.quickDetails, null) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + StreamLabel("${position + 1} of $count") + if (image.image.data.mediaType == MediaType.VIDEO) { + VideoStreamDetails( + chosenStreams = image.chosenStreams, + numberOfVersions = 0, + ) + } else { + image.image.data.let { + if (it.width != null && it.height != null) { + StreamLabel("${it.width}x${it.height}") + } + } + } + } + OverviewText( + overview = image.image.data.overview ?: "", + maxLines = 3, + onClick = {}, + modifier = Modifier.fillMaxWidth(.75f), + ) + val playPauseState = rememberPlayPauseButtonState(player) + ImageControlsOverlay( + slideshowEnabled = slideshowEnabled, + slideshowControls = slideshowControls, + isImageClip = image.image.data.mediaType == MediaType.VIDEO, + bringIntoViewRequester = bringIntoViewRequester, + onZoom = onZoom, + onRotate = onRotate, + onReset = onReset, + moreOnClick = moreOnClick, + playPauseOnClick = playPauseState::onClick, + isPlaying = playPauseState.showPlay, + onShowFilterDialogClick = onShowFilterDialogClick, + onDismiss = {}, + modifier = + Modifier + .fillMaxWidth(), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt new file mode 100644 index 00000000..d07f054a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageFilterSliders.kt @@ -0,0 +1,296 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.view.Gravity +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.tv.material3.Button +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.ui.components.SliderBar +import com.github.damontecres.wholphin.ui.components.SliderColors +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +const val DRAG_THROTTLE_DELAY = 50L + +@Composable +fun ImageFilterSliders( + filter: VideoFilter, + showVideoOptions: Boolean, + showSaveButton: Boolean, + showSaveGalleryButton: Boolean, + onChange: (VideoFilter) -> Unit, + onClickSave: () -> Unit, + onClickSaveGallery: () -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + item { + SliderBarRow( + title = R.string.brightness, + value = filter.brightness, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(brightness = it)) }, + valueFormater = { "$it%" }, + ) + } + item { + SliderBarRow( + title = R.string.contrast, + value = filter.contrast, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(contrast = it)) }, + valueFormater = { "$it%" }, + ) + } + item { + SliderBarRow( + title = R.string.saturation, + value = filter.saturation, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(saturation = it)) }, + valueFormater = { "$it%" }, + ) + } + if (showVideoOptions) { + item { + SliderBarRow( + title = R.string.hue, + value = filter.hue, + min = 0, + max = 360, + onChange = { onChange.invoke(filter.copy(hue = it)) }, + valueFormater = { "$it\u00b0" }, + ) + } + } + item { + SliderBarRow( + title = R.string.red, + value = filter.red, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(red = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Red.copy(alpha = .75f), + activeUnfocused = Color.Red.copy(alpha = .75f), + inactiveFocused = Color.Red.copy(alpha = .33f), + inactiveUnfocused = Color.Red.copy(alpha = .33f), + ), + ) + } + item { + SliderBarRow( + title = R.string.green, + value = filter.green, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(green = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Green.copy(alpha = .75f), + activeUnfocused = Color.Green.copy(alpha = .75f), + inactiveFocused = Color.Green.copy(alpha = .33f), + inactiveUnfocused = Color.Green.copy(alpha = .33f), + ), + ) + } + item { + SliderBarRow( + title = R.string.blue, + value = filter.blue, + min = 0, + max = 200, + onChange = { onChange.invoke(filter.copy(blue = it)) }, + valueFormater = { "$it%" }, + colors = + SliderColors( + activeFocused = Color.Blue.copy(alpha = .75f), + activeUnfocused = Color.Blue.copy(alpha = .75f), + inactiveFocused = Color.Blue.copy(alpha = .33f), + inactiveUnfocused = Color.Blue.copy(alpha = .33f), + ), + ) + } + if (showVideoOptions) { + item { + SliderBarRow( + title = R.string.blur, + value = filter.blur, + min = 0, + max = 250, + onChange = { onChange.invoke(filter.copy(blur = it)) }, + valueFormater = { "${it}px" }, + ) + } + } + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier, + ) { + if (showSaveButton) { + Button( + onClick = onClickSave, + ) { + Text(text = stringResource(R.string.save)) + } + } + if (showSaveGalleryButton) { + Button( + onClick = onClickSaveGallery, + ) { + Text(text = stringResource(R.string.save_for_album)) + } + } + Button( + onClick = { onChange(VideoFilter()) }, + ) { + Text(text = stringResource(R.string.reset)) + } + } + } + } + } +} + +@Composable +fun SliderBarRow( + @StringRes title: Int, + value: Int, + min: Int, + max: Int, + onChange: (Int) -> Unit, + valueFormater: (Int) -> String, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + interval: Int = 1, + colors: SliderColors = SliderColors.default(), +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + Text( + text = stringResource(title), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(96.dp), + ) + SliderBar( + value = value.toLong(), + min = min.toLong(), + max = max.toLong(), + interval = interval, + onChange = { + onChange.invoke(it.toInt()) + }, + colors = colors, + interactionSource = interactionSource, + enableWrapAround = true, + modifier = Modifier.weight(1f), + ) + Text( + text = valueFormater(value), + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.width(48.dp), + ) + } +} + +@Composable +fun ImageFilterDialog( + filter: VideoFilter, + showVideoOptions: Boolean, + showSaveGalleryButton: Boolean, + onChange: (VideoFilter) -> Unit, + onClickSave: () -> Unit, + onClickSaveGallery: () -> Unit, + onDismissRequest: () -> Unit, + modifier: Modifier = Modifier, +) { + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + val dialogWindowProvider = LocalView.current.parent as? DialogWindowProvider + dialogWindowProvider?.window?.let { window -> + window.setGravity(Gravity.TOP or Gravity.END) + window.setDimAmount(0f) + } + + Box( + modifier = + modifier + .wrapContentSize() + .padding(8.dp) + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = .4f)) + .fillMaxWidth(.4f), + ) { + ImageFilterSliders( + filter = filter, + showVideoOptions = showVideoOptions, + showSaveButton = true, + showSaveGalleryButton = showSaveGalleryButton, + onChange = onChange, + onClickSave = onClickSave, + onClickSaveGallery = onClickSaveGallery, + modifier = Modifier.padding(8.dp), + ) + } + } +} + +@Preview +@Composable +private fun ImageFilterSlidersPreview() { + WholphinTheme { + ImageFilterSliders( + filter = VideoFilter(), + showVideoOptions = true, + onChange = {}, + onClickSave = {}, + onClickSaveGallery = {}, + showSaveButton = true, + showSaveGalleryButton = true, + modifier = Modifier.padding(8.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt new file mode 100644 index 00000000..a6b16ff0 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageLoadingPlaceholder.kt @@ -0,0 +1,54 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.isNotNullOrBlank + +@Composable +fun ImageLoadingPlaceholder( + thumbnailUrl: String?, + showThumbnail: Boolean, + colorFilter: ColorFilter?, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier) { + if (showThumbnail && thumbnailUrl.isNotNullOrBlank()) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(thumbnailUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + modifier = + Modifier + .fillMaxSize() + .align(Alignment.Center) + .alpha(.75f) + .blur(4.dp), + ) + } + CircularProgress( + Modifier + .size(80.dp) + .align(Alignment.Center), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt new file mode 100644 index 00000000..b177be74 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/ImageOverlay.kt @@ -0,0 +1,146 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.tv.material3.Icon +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup + +@androidx.annotation.OptIn(UnstableApi::class) +@Composable +fun ImageOverlay( + onDismiss: () -> Unit, + player: Player, + slideshowControls: SlideshowControls, + slideshowEnabled: Boolean, + position: Int, + count: Int, + image: ImageState, + onClickItem: (BaseItem) -> Unit, + onLongClickItem: (BaseItem) -> Unit, + onZoom: (Float) -> Unit, + onRotate: (Int) -> Unit, + onReset: () -> Unit, + onShowFilterDialogClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + var showDialog by remember { mutableStateOf(null) } + + val moreDialogParams = + remember { + DialogParams( + fromLongClick = false, + title = "TODO", + items = + listOf( + DialogItem( + headlineContent = { + Text( + text = + if (slideshowEnabled) { + stringResource(R.string.stop_slideshow) + } else { + stringResource(R.string.play_slideshow) + }, + ) + }, + leadingContent = { + val icon = + if (slideshowEnabled) { + R.drawable.baseline_pause_24 + } else { + R.drawable.baseline_play_arrow_24 + } + Icon( + painter = painterResource(icon), + contentDescription = null, + ) + }, + onClick = { + if (slideshowEnabled) { + slideshowControls.stopSlideshow() + } else { + slideshowControls.startSlideshow() + } + }, + ), + DialogItem( + headlineContent = { + Text( + text = stringResource(R.string.filter), + ) + }, + leadingContent = { + Text( + text = stringResource(R.string.fa_sliders), + fontFamily = FontAwesome, + ) + }, + onClick = onShowFilterDialogClick, + ), + ), + ) + } + + val horizontalPadding = 16.dp + LazyColumn( + contentPadding = + PaddingValues( + start = horizontalPadding, + end = horizontalPadding, + top = 16.dp, + bottom = 16.dp, + ), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + item { + ImageDetailsHeader( + onDismiss = onDismiss, + slideshowEnabled = slideshowEnabled, + slideshowControls = slideshowControls, + player = player, + image = image, + position = position, + count = count, + moreOnClick = { + showDialog = moreDialogParams + }, + onZoom = onZoom, + onRotate = onRotate, + onReset = onReset, + onShowFilterDialogClick = onShowFilterDialogClick, + modifier = Modifier.fillMaxWidth(), + ) + } + } + showDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { showDialog = null }, + waitToLoad = params.fromLongClick, + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt new file mode 100644 index 00000000..a513715d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt @@ -0,0 +1,509 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.annotation.SuppressLint +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorMatrixColorFilter +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleStartEffect +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.PlayerSurface +import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW +import androidx.media3.ui.compose.modifiers.resizeWithContentScale +import androidx.media3.ui.compose.state.rememberPresentationState +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.SubcomposeAsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.size.Size +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.findActivity +import com.github.damontecres.wholphin.ui.keepScreenOn +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad +import com.github.damontecres.wholphin.ui.playback.isDpad +import com.github.damontecres.wholphin.ui.playback.isEnterKey +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.MediaType +import timber.log.Timber +import kotlin.math.abs + +private const val TAG = "ImagePage" +private const val DEBUG = false + +@SuppressLint("ConfigurationScreenWidthHeight") +@OptIn(UnstableApi::class) +@Composable +fun SlideshowPage( + slideshow: Destination.Slideshow, + modifier: Modifier = Modifier, + viewModel: SlideshowViewModel = + hiltViewModel( + creationCallback = { + it.create(slideshow) + }, + ), +) { + val context = LocalContext.current + + val loadingState by viewModel.loadingState.observeAsState(ImageLoadingState.Loading) + val imageFilter by viewModel.imageFilter.observeAsState(VideoFilter()) + val position by viewModel.position.observeAsState(0) + val pager by viewModel.pager.observeAsState() + val imageState by viewModel.image.observeAsState() + + var zoomFactor by rememberSaveable { mutableFloatStateOf(1f) } + val isZoomed = zoomFactor * 100 > 102 + var rotation by rememberSaveable { mutableFloatStateOf(0f) } + var showOverlay by rememberSaveable { mutableStateOf(false) } + var showFilterDialog by rememberSaveable { mutableStateOf(false) } + var panX by rememberSaveable { mutableFloatStateOf(0f) } + var panY by rememberSaveable { mutableFloatStateOf(0f) } + + val slideshowControls = + object : SlideshowControls { + override fun startSlideshow() { + showOverlay = false + viewModel.startSlideshow() + } + + override fun stopSlideshow() { + viewModel.stopSlideshow() + } + } + + val rotateAnimation: Float by animateFloatAsState( + targetValue = rotation, + label = "image_rotation", + ) + val zoomAnimation: Float by animateFloatAsState( + targetValue = zoomFactor, + label = "image_zoom", + ) + val panXAnimation: Float by animateFloatAsState( + targetValue = panX, + label = "image_panX", + ) + val panYAnimation: Float by animateFloatAsState( + targetValue = panY, + label = "image_panY", + ) + + val slideshowState by viewModel.slideshow.collectAsState() + val slideshowActive by viewModel.slideshowActive.collectAsState(false) + + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.tryRequestFocus() + } + + val density = LocalDensity.current + val screenHeight = LocalWindowInfo.current.containerSize.height + val screenWidth = LocalWindowInfo.current.containerSize.width + + val maxPanX = screenWidth * .75f + val maxPanY = screenHeight * .75f + + fun reset(resetRotate: Boolean) { + zoomFactor = 1f + panX = 0f + panY = 0f + if (resetRotate) rotation = 0f + } + + fun pan( + xFactor: Int, + yFactor: Int, + ) { + if (xFactor != 0) { + panX = (panX + with(density) { xFactor.dp.toPx() }).coerceIn(-maxPanX, maxPanX) + } + if (yFactor != 0) { + panY = (panY + with(density) { yFactor.dp.toPx() }).coerceIn(-maxPanY, maxPanY) + } + } + + fun zoom(factor: Float) { + if (factor < 0) { + val diffFactor = factor / (zoomFactor - 1f) + // zooming out + val panXDiff = abs(panX * diffFactor) + val panYDiff = abs(panY * diffFactor) + if (DEBUG) { + Timber.d( + "zoomFactor=$zoomFactor, factor=$factor, panX=$panX, panY=$panY, panXDiff=$panXDiff, panYDiff=$panYDiff", + ) + } + if (panX > 0f) { + panX -= panXDiff + } else if (panX < 0f) { + panX += panXDiff + } + if (panY > 0f) { + panY -= panYDiff + } else if (panY < 0f) { + panY += panYDiff + } + } + zoomFactor = (zoomFactor + factor).coerceIn(1f, 5f) + if (!isZoomed) { + // Always reset if not zoomed + panX = 0f + panY = 0f + } + } + + LaunchedEffect(imageState) { + reset(true) + } + val player = viewModel.player + val presentationState = rememberPresentationState(player) + LaunchedEffect(slideshowActive) { + player.repeatMode = + if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE + context.findActivity()?.keepScreenOn(slideshowActive) + } + DisposableEffect(Unit) { + onDispose { + context.findActivity()?.keepScreenOn(false) + } + } + + var longPressing by remember { mutableStateOf(false) } + + val contentModifier = + Modifier + .clickable( + interactionSource = null, + indication = null, + onClick = { + showOverlay = !showOverlay + }, + ) + + Box( + modifier = + modifier + .background(Color.Black) + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { + val isOverlayShowing = showOverlay || showFilterDialog + var result = false + if (!isOverlayShowing) { + if (longPressing && it.type == KeyEventType.KeyUp) { + // User stopped long pressing, so cancel the zooming action, but still consume the event so it doesn't move the image + longPressing = false + return@onKeyEvent true + } + longPressing = + it.nativeKeyEvent.isLongPress || + it.nativeKeyEvent.repeatCount > 0 + if (longPressing) { + when (it.key) { + Key.DirectionUp -> zoom(.05f) + Key.DirectionDown -> zoom(-.05f) + + // These work, but feel awkward because Up/Down zoom, so you can't long press them to pan + // Key.DirectionLeft -> panX += with(density) { 15.dp.toPx() } + // Key.DirectionRight -> panX -= with(density) { 15.dp.toPx() } + } + return@onKeyEvent true + } + } + if (it.type != KeyEventType.KeyUp) { + result = false + } else if (!isOverlayShowing && isZoomed && isDirectionalDpad(it)) { + // Image is zoomed in + when (it.key) { + Key.DirectionLeft -> pan(30, 0) + Key.DirectionRight -> pan(-30, 0) + Key.DirectionUp -> pan(0, 30) + Key.DirectionDown -> pan(0, -30) + } + result = true + } else if (!isOverlayShowing && isZoomed && it.key == Key.Back) { + reset(false) + result = true + } else if (!isOverlayShowing && (it.key == Key.DirectionLeft || it.key == Key.DirectionRight)) { + when (it.key) { + Key.DirectionLeft, Key.DirectionUpLeft, Key.DirectionDownLeft -> { + if (!viewModel.previousImage()) { + Toast + .makeText( + context, + R.string.slideshow_at_beginning, + Toast.LENGTH_SHORT, + ).show() + } + } + + Key.DirectionRight, Key.DirectionUpRight, Key.DirectionDownRight -> { + if (!viewModel.nextImage()) { + Toast + .makeText( + context, + R.string.no_more_images, + Toast.LENGTH_SHORT, + ).show() + } + } + } + } else if (isOverlayShowing && it.key == Key.Back) { + showOverlay = false + viewModel.unpauseSlideshow() + result = true + } else if (!isOverlayShowing && (isDpad(it) || isEnterKey(it))) { + showOverlay = true + viewModel.pauseSlideshow() + result = true + } + if (result) { + // Handled the key, so reset the slideshow timer + viewModel.pulseSlideshow() + } + result + }, + ) { + when (loadingState) { + ImageLoadingState.Error -> { + ErrorMessage("Error loading image", null) + } + + ImageLoadingState.Loading -> { + LoadingPage() + } + + is ImageLoadingState.Success -> { + imageState?.let { imageState -> + if (imageState.image.data.mediaType == MediaType.VIDEO) { + LaunchedEffect(imageState.id) { + val mediaItem = + MediaItem + .Builder() + .setUri(imageState.url) + .build() + player.setMediaItem(mediaItem) + player.repeatMode = + if (slideshowState.enabled) { + Player.REPEAT_MODE_OFF + } else { + Player.REPEAT_MODE_ONE + } + player.prepare() + player.play() + viewModel.pulseSlideshow(Long.MAX_VALUE) + } + LifecycleStartEffect(Unit) { + onStopOrDispose { + player.stop() + } + } + val contentScale = ContentScale.Fit + val scaledModifier = + contentModifier.resizeWithContentScale( + contentScale, + presentationState.videoSizeDp, + ) + PlayerSurface( + player = player, + surfaceType = SURFACE_TYPE_SURFACE_VIEW, + modifier = + scaledModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + }.rotate(rotateAnimation), + ) + if (presentationState.coverSurface) { + Box( + Modifier + .matchParentSize() + .background(Color.Black), + ) + } + } else { + val colorFilter = + remember(imageState.id, imageFilter) { + if (imageFilter.hasImageFilter()) { + ColorMatrixColorFilter(imageFilter.colorMatrix) + } else { + null + } + } + // If the image loading is large, show the thumbnail while waiting + // TODO + val showLoadingThumbnail = true + SubcomposeAsyncImage( + modifier = + contentModifier + .fillMaxSize() + .graphicsLayer { + scaleX = zoomAnimation + scaleY = zoomAnimation + translationX = panXAnimation + translationY = panYAnimation + + val xTransform = + (screenWidth - panXAnimation) / (screenWidth * 2) + val yTransform = + (screenHeight - panYAnimation) / (screenHeight * 2) + if (DEBUG) { + Timber.d( + "graphicsLayer: xTransform=$xTransform, yTransform=$yTransform", + ) + } + + transformOrigin = TransformOrigin(xTransform, yTransform) + }.rotate(rotateAnimation), + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageState.url) + .size(Size.ORIGINAL) + .crossfade(!showLoadingThumbnail) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + colorFilter = colorFilter, + error = { + Text( + modifier = + Modifier + .align(Alignment.Center), + text = "Error loading image", + color = MaterialTheme.colorScheme.onBackground, + ) + }, + loading = { + ImageLoadingPlaceholder( + thumbnailUrl = imageState.thumbnailUrl, + showThumbnail = showLoadingThumbnail, + colorFilter = colorFilter, + modifier = Modifier.fillMaxSize(), + ) + }, + // Ensure that if an image takes a long time to load, it won't be skipped + onLoading = { + viewModel.pulseSlideshow(Long.MAX_VALUE) + }, + onSuccess = { + viewModel.pulseSlideshow() + }, + onError = { + Timber.e( + it.result.throwable, + "Error loading image ${imageState.id}", + ) + Toast + .makeText( + context, + "Error loading image: ${it.result.throwable.localizedMessage}", + Toast.LENGTH_LONG, + ).show() + viewModel.pulseSlideshow() + }, + ) + } + } + } + } + AnimatedVisibility( + showOverlay, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier.align(Alignment.BottomStart), + ) { + imageState?.let { imageState -> + ImageOverlay( + modifier = + contentModifier + .fillMaxWidth() + .background(AppColors.TransparentBlack50), + onDismiss = { showOverlay = false }, + player = player, + slideshowControls = slideshowControls, + slideshowEnabled = slideshowState.enabled, + image = imageState, + position = position, + count = pager?.size ?: -1, + onClickItem = {}, + onLongClickItem = {}, + onZoom = ::zoom, + onRotate = { rotation += it }, + onReset = { reset(true) }, + onShowFilterDialogClick = { + showFilterDialog = true + showOverlay = false + viewModel.pauseSlideshow() + }, + ) + } + } + AnimatedVisibility(showFilterDialog) { + ImageFilterDialog( + filter = imageFilter, + showVideoOptions = false, + showSaveGalleryButton = true, + onChange = viewModel::updateImageFilter, + onClickSave = viewModel::saveImageFilter, + onClickSaveGallery = viewModel::saveGalleryFilter, + onDismissRequest = { + showFilterDialog = false + viewModel.unpauseSlideshow() + viewModel.pulseSlideshow() + }, + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt new file mode 100644 index 00000000..4d2d0662 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/slideshow/SlideshowViewModel.kt @@ -0,0 +1,445 @@ +package com.github.damontecres.wholphin.ui.slideshow + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Stable +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import androidx.media3.common.Player +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.PlaybackEffect +import com.github.damontecres.wholphin.data.model.VideoFilter +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.services.ImageUrlService +import com.github.damontecres.wholphin.services.PlayerFactory +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.PhotoItemFields +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.onMain +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.ui.util.ThrottledLiveData +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.api.client.extensions.videosApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.MediaType +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import kotlin.properties.Delegates + +@HiltViewModel(assistedFactory = SlideshowViewModel.Factory::class) +class SlideshowViewModel + @AssistedInject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val playerFactory: PlayerFactory, + private val playbackEffectDao: PlaybackEffectDao, + private val serverRepository: ServerRepository, + private val imageUrlService: ImageUrlService, + private val userPreferencesService: UserPreferencesService, + @Assisted val slideshowSettings: Destination.Slideshow, + ) : ViewModel(), + Player.Listener { + @AssistedFactory + interface Factory { + fun create(slideshow: Destination.Slideshow): SlideshowViewModel + } + + val player by lazy { + playerFactory.createVideoPlayer() + } + + private var saveFilters = true + + /** + * Whether slideshow mode is on or off + */ + private val _slideshow = MutableStateFlow(SlideshowState(false, false)) + val slideshow: StateFlow = _slideshow + + /** + * Whether the slideshow is actively running meaning slideshow mode is ON and is currently NOT paused + */ + val slideshowActive = slideshow.map { it.enabled && !it.paused } + + var slideshowDelay by Delegates.notNull() + + // private val album = MutableLiveData() + private val _pager = MutableLiveData>() + val pager: LiveData> = _pager.map { it } + val position = MutableLiveData(0) + + private val _image = MutableLiveData() + val image: LiveData = _image + + val loadingState = MutableLiveData(ImageLoadingState.Loading) + private val _imageFilter = MutableLiveData(VideoFilter()) + val imageFilter = ThrottledLiveData(_imageFilter, 500L) + + private var albumImageFilter = VideoFilter() + + init { + addCloseable { + player.removeListener(this@SlideshowViewModel) + player.release() + } + player.addListener(this@SlideshowViewModel) + viewModelScope.launchIO { + val photoPrefs = userPreferencesService.getCurrent().appPreferences.photoPreferences + slideshowDelay = + photoPrefs.slideshowDuration.takeIf { it >= AppPreference.SlideshowDuration.min } + ?: AppPreference.SlideshowDuration.defaultValue +// val album = +// api.userLibraryApi +// .getItem( +// itemId = slideshowSettings.parentId, +// ).content +// .let { BaseItem(it, false) } +// this@SlideshowViewModel.album.setValueOnMain(album) + val includeItemTypes = + if (photoPrefs.slideshowPlayVideos) { + listOf(BaseItemKind.PHOTO, BaseItemKind.VIDEO) + } else { + listOf(BaseItemKind.PHOTO) + } + val request = + slideshowSettings.filter.filter.applyTo( + GetItemsRequest( + parentId = slideshowSettings.parentId, + includeItemTypes = includeItemTypes, + fields = PhotoItemFields, + recursive = true, + sortBy = listOf(slideshowSettings.sortAndDirection.sort), + sortOrder = listOf(slideshowSettings.sortAndDirection.direction), + ), + ) + serverRepository.currentUser.value?.let { user -> + val filter = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + )?.videoFilter + if (filter != null) { + Timber.v("Got filter for album %s", slideshowSettings.parentId) + albumImageFilter = filter + } + } + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope) + .init(slideshowSettings.index) + this@SlideshowViewModel._pager.setValueOnMain(pager) + updatePosition(slideshowSettings.index)?.join() + if (slideshowSettings.startSlideshow) onMain { startSlideshow() } + } + } + + fun nextImage(): Boolean { + val size = pager.value?.size + val newPosition = position.value!! + 1 + return if (size != null && newPosition < size) { + updatePosition(newPosition) + true + } else { + false + } + } + + fun previousImage(): Boolean { + val newPosition = position.value!! - 1 + return if (newPosition >= 0) { + updatePosition(newPosition) + true + } else { + false + } + } + + fun updatePosition(position: Int): Job? = + _pager.value?.let { pager -> + viewModelScope.launchIO { + try { + val image = pager.getBlocking(position) + Timber.v("Got image for $position: ${image != null}") + if (image != null) { + this@SlideshowViewModel.position.setValueOnMain(position) + + val url = + if (image.data.mediaType == MediaType.VIDEO) { + // TODO this assumes direct play + api.videosApi.getVideoStreamUrl( + itemId = image.id, + ) + } else { + api.libraryApi.getDownloadUrl(image.id) + } + val chosenStreams = + if (image.data.mediaType == MediaType.VIDEO) { + image.data.mediaSources?.firstOrNull()?.let { source -> + val video = + source.mediaStreams?.firstOrNull { it.type == MediaStreamType.VIDEO } + val audio = + source.mediaStreams?.firstOrNull { it.type == MediaStreamType.AUDIO } + ChosenStreams( + itemPlayback = null, + plc = null, + itemId = image.id, + source = source, + videoStream = video, + audioStream = audio, + subtitleStream = null, + subtitlesDisabled = false, + ) + } + } else { + null + } + + val imageState = + ImageState( + image, + url, + imageUrlService.getItemImageUrl(image, ImageType.THUMB), + chosenStreams, + ) + // reset image filter + updateImageFilter(albumImageFilter) + if (saveFilters) { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + val vf = + playbackEffectDao + .getPlaybackEffect( + user.rowId, + image.id, + BaseItemKind.PHOTO, + ) + if (vf != null && vf.videoFilter.hasImageFilter()) { + Timber.d( + "Loaded VideoFilter for image ${image.id}", + ) + withContext(Dispatchers.Main) { + // Pause throttling so that the image loads with the filter applied immediately + imageFilter.stopThrottling(true) + updateImageFilter(vf.videoFilter) + imageFilter.startThrottling() + } + } + withContext(Dispatchers.Main) { + _image.value = imageState + loadingState.value = + ImageLoadingState.Success(imageState) + } + } + } + } else { + withContext(Dispatchers.Main) { + _image.value = imageState + loadingState.value = ImageLoadingState.Success(imageState) + } + } + } else { + loadingState.setValueOnMain(ImageLoadingState.Error) + } + } catch (ex: Exception) { + Timber.e(ex) + loadingState.setValueOnMain(ImageLoadingState.Error) + } + } + } + + private var slideshowJob: Job? = null + + fun startSlideshow() { + _slideshow.update { + SlideshowState(enabled = true, paused = false) + } + if (_image.value + ?.image + ?.data + ?.mediaType != MediaType.VIDEO + ) { + pulseSlideshow() + } + } + + fun stopSlideshow() { + slideshowJob?.cancel() + _slideshow.update { + SlideshowState(enabled = false, paused = false) + } + } + + fun pauseSlideshow() { + Timber.v("pauseSlideshow") + _slideshow.update { + if (it.enabled) { + slideshowJob?.cancel() + it.copy(paused = true) + } else { + it + } + } + } + + fun unpauseSlideshow() { + Timber.v("unpauseSlideshow") + _slideshow.update { + if (it.enabled) { + it.copy(paused = false) + } else { + it + } + } + } + + fun pulseSlideshow() = pulseSlideshow(slideshowDelay) + + fun pulseSlideshow(milliseconds: Long) { + Timber.v("pulseSlideshow $milliseconds") + slideshowJob?.cancel() + slideshowJob = + viewModelScope + .launchIO { + delay(milliseconds) +// Timber.v("pulseSlideshow after delay") + if (slideshowActive.first()) { + // Next image or loop to beginning + if (!nextImage()) updatePosition(0) + } + }.apply { + invokeOnCompletion { if (it !is CancellationException) pulseSlideshow() } + } + } + + fun updateImageFilter(newFilter: VideoFilter) { + viewModelScope.launchIO { + _imageFilter.setValueOnMain(newFilter) + } + } + + fun saveImageFilter() { + image.value?.let { + viewModelScope.launchIO { + val vf = _imageFilter.value + if (vf != null) { + serverRepository.currentUser.value?.let { user -> + playbackEffectDao + .insert( + PlaybackEffect( + user.rowId, + it.image.id, + BaseItemKind.PHOTO, + vf, + ), + ) + Timber.d("Saved VideoFilter for image %s", it.image.id) + withContext(Dispatchers.Main) { + showToast( + context, + "Saved", + Toast.LENGTH_SHORT, + ) + } + } + } + } + } + } + + fun saveGalleryFilter() { + viewModelScope.launchIO(ExceptionHandler(autoToast = true)) { + val vf = _imageFilter.value + if (vf != null) { + albumImageFilter = vf + serverRepository.currentUser.value?.let { user -> + playbackEffectDao + .insert( + PlaybackEffect( + user.rowId, + slideshowSettings.parentId, + BaseItemKind.PHOTO_ALBUM, + vf, + ), + ) + Timber.d("Saved VideoFilter for album %s", slideshowSettings.parentId) + withContext(Dispatchers.Main) { + showToast( + context, + "Saved", + Toast.LENGTH_SHORT, + ) + } + } + } + } + } + + override fun onPlaybackStateChanged(playbackState: Int) { + if (playbackState == Player.STATE_ENDED) { + pulseSlideshow(slideshowDelay) + } + } + } + +interface SlideshowControls { + fun startSlideshow() + + fun stopSlideshow() +} + +sealed class ImageLoadingState { + data object Loading : ImageLoadingState() + + data object Error : ImageLoadingState() + + data class Success( + val image: ImageState, + ) : ImageLoadingState() +} + +@Stable +data class ImageState( + val image: BaseItem, + val url: String, + val thumbnailUrl: String?, + val chosenStreams: ChosenStreams?, +) { + val id: UUID get() = image.id +} + +data class SlideshowState( + val enabled: Boolean, + val paused: Boolean, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt new file mode 100644 index 00000000..5a27f818 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ThrottledLiveData.kt @@ -0,0 +1,82 @@ +package com.github.damontecres.wholphin.ui.util + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +/** + * LiveData throttling value emissions so they don't happen more often than [delayMs]. + * + * From https://stackoverflow.com/a/62467521 + */ +class ThrottledLiveData( + source: LiveData, + delayMs: Long, +) : MediatorLiveData() { + val handler = Handler(Looper.getMainLooper()) + var delayMs = delayMs + private set + + private var isValueDelayed = false + private var delayedValue: T? = null + private var delayRunnable: Runnable? = null + set(value) { + field?.let { handler.removeCallbacks(it) } + value?.let { handler.postDelayed(it, delayMs) } + field = value + } + private val objDelayRunnable = Runnable { if (consumeDelayedValue()) startDelay() } + + init { + addSource(source) { newValue -> + if (delayRunnable == null) { + value = newValue + startDelay() + } else { + isValueDelayed = true + delayedValue = newValue + } + } + } + + /** Start throttling or modify the delay. If [newDelay] is `0` (default) reuse previous delay value. */ + fun startThrottling(newDelay: Long = 0L) { + require(newDelay >= 0L) + when { + newDelay > 0 -> delayMs = newDelay + delayMs < 0 -> delayMs *= -1 + delayMs > 0 -> return + else -> throw kotlin.IllegalArgumentException("newDelay cannot be zero if old delayMs is zero") + } + } + + /** Stop throttling, if [immediate] emit any pending value now. */ + fun stopThrottling(immediate: Boolean = false) { + if (delayMs <= 0) return + delayMs *= -1 + if (immediate) consumeDelayedValue() + } + + override fun onInactive() { + super.onInactive() + consumeDelayedValue() + } + + // start counting the delay or clear it if conditions are not met + private fun startDelay() { + delayRunnable = if (delayMs > 0 && hasActiveObservers()) objDelayRunnable else null + } + + private fun consumeDelayedValue(): Boolean { + delayRunnable = null + return if (isValueDelayed) { + value = delayedValue + delayedValue = null + isValueDelayed = false + true + } else { + false + } + } +} diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index eac223b0..41fd78ef 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -160,6 +160,11 @@ message AdvancedPreferences { int64 image_disk_cache_size_bytes = 1; } +message PhotoPreferences{ + int64 slideshow_duration = 1; + bool slideshow_play_videos = 2; +} + message AppPreferences { // The currently signed in server and user IDs, mostly for restoring a session string current_server_id = 1; @@ -175,4 +180,5 @@ message AppPreferences { bool debug_logging = 9; AdvancedPreferences advanced_preferences = 10; bool sign_in_automatically = 11; + PhotoPreferences photo_preferences = 12; } diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index ae2a4ae1..371560a1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -9,6 +9,7 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c85ef303..c0021de1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ Confirm Continue watching Critic Rating - %.1f seconds + %.2f seconds Default Delete Died @@ -465,6 +465,26 @@ HDR subtitle style Image subtitle opacity + Brightness + Constrast + Saturation + Hue + Red + Green + Blue + Blur + Save for album + Play slideshow + Stop slideshow + At beginning + No more photos + Rotate left + Rotate right + Zoom in + Zoom out + Slideshow duration + Play videos during slideshow + Disabled Lowest diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10832e4a..92e42737 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -97,6 +97,7 @@ androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" } +androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" } coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } From aeaecc0f59fb49f4821b32c23241cdee11d25607 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Wed, 4 Feb 2026 19:50:29 -0600 Subject: [PATCH 3/3] Fix suggestions mixing content from different libraries and update recommendation logic (#644) ## Description This PR aims to fix #638 where the "Suggestions" row was mixing up content from different libraries that shared the same library type. For example you were in the Anime library, you'd see regular TV shows showing up, as the TV Shows and Anime libraries would both be "Show" libraries. As @damontecres mentioned in the issue discussion, the `/Items/Suggestions` endpoint Wholphin is using just returns random items from the same library type, with no special logic or recommendation algorithm. I also looked at how the official WebUI handles this. It basically just mixes "Resume," "Next Up," and "Latest Items" together. That felt a bit redundant since we usually have dedicated rows for those things anyway; I wanted to try something that helps discover new content. I replaced the broken endpoint with a few custom `GetItemsRequest` calls. These work correctly with the `ParentId` filter, so we only get results from the library we are actually looking at. Additionally, I added in some logic that takes into account what the user has been watching to tailor the suggestions a little bit. The new logic uses a 40/30/30 mix (this can of course be tweaked as needed) - Tailored (40%): Grabs your recently watched items from this library, checks their genres, and returns items from the same genre. - Random (30%): Random unwatched stuff from this library. - New (30%): Recently added items. This is implemented using only `GetItemsRequest` API calls using filters, rather than any specialized endpoints like the `/Items/Suggestions` endpoint. This means the feature should be predictable and stable. If Jellyfin ever adds an actual recommendation algorithm and endpoint to call it, I imagine we would want to switch to that in the future though. I also added some failsafe logic to make sure there will be diversity in the "recently watched" items that get pulled, in case a user might have recently binged a TV show. We wouldn't want every single recommendation to be based off of that one show. To fix this, I changed the query to fetch the last 20 history items instead of just the top 3. Then, we filter that list client-side to find unique Series IDs. If the user watched 10 episodes of One Piece in a row, the logic collapses them into a single "One Piece" entry and moves on to the next distinct show they watched. This guarantees we get variety in the source material for recommendations. This is a work in progress right now, mainly due to performance. For my server it takes about 10-15 seconds to load the Suggestions row when I tested. This is unacceptable in my opinion, especially if we ever intend to add a Suggestions row like this to the home page like Plex does. I have some ideas on how to improve performance, but I'm open to suggestions. ### Related issues Resolves #638 ### Screenshots Before: suggestions1 After: suggestions3 ### AI/LLM usage I used Claude Code to help write the Kotlin Coroutines so the API calls run at the same time. I also used it to write the tests in `TestSuggestionsLogic.kt`. --------- Co-authored-by: Damontecres --- app/build.gradle.kts | 3 + .../damontecres/wholphin/MainActivity.kt | 4 + .../services/PlaybackLifecycleObserver.kt | 5 + .../wholphin/services/SuggestionService.kt | 97 ++++++ .../wholphin/services/SuggestionsCache.kt | 216 +++++++++++++ .../services/SuggestionsSchedulerService.kt | 100 ++++++ .../wholphin/services/SuggestionsWorker.kt | 230 ++++++++++++++ .../wholphin/services/hilt/AppModule.kt | 7 + .../tvprovider/TvProviderSchedulerService.kt | 2 +- .../ui/components/RecommendedMovie.kt | 78 +++-- .../ui/components/RecommendedTvShow.kt | 104 +++--- .../services/SuggestionServiceTest.kt | 261 ++++++++++++++++ .../wholphin/services/SuggestionsCacheTest.kt | 234 ++++++++++++++ .../SuggestionsSchedulerServiceTest.kt | 90 ++++++ .../services/SuggestionsWorkerTest.kt | 154 +++++++++ .../wholphin/test/TestSuggestionsLogic.kt | 295 ++++++++++++++++++ gradle/libs.versions.toml | 5 + 17 files changed, 1809 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 37729e19..5a1dbd68 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,7 @@ dependencies { implementation(libs.androidx.tv.foundation) implementation(libs.androidx.tv.material) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.datastore) implementation(libs.protobuf.kotlin.lite) @@ -299,5 +300,7 @@ dependencies { testImplementation(libs.mockk.android) testImplementation(libs.mockk.agent) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index 567f8117..dcc25af0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.SuggestionsSchedulerService import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UserSwitchListener import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient @@ -119,6 +120,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tvProviderSchedulerService: TvProviderSchedulerService + @Inject + lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt index 4be082a5..cc9f5779 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PlaybackLifecycleObserver.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import com.github.damontecres.wholphin.ui.nav.Destination import dagger.hilt.android.scopes.ActivityRetainedScoped +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -16,6 +19,7 @@ class PlaybackLifecycleObserver private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, private val themeSongPlayer: ThemeSongPlayer, + private val suggestionsCache: SuggestionsCache, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null @@ -48,5 +52,6 @@ class PlaybackLifecycleObserver override fun onStop(owner: LifecycleOwner) { themeSongPlayer.stop() + CoroutineScope(Dispatchers.Main).launch { suggestionsCache.save() } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt new file mode 100644 index 00000000..a38c713d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -0,0 +1,97 @@ +package com.github.damontecres.wholphin.services + +import androidx.lifecycle.asFlow +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +sealed interface SuggestionsResource { + data object Loading : SuggestionsResource + + data class Success( + val items: List, + ) : SuggestionsResource + + data object Empty : SuggestionsResource +} + +@Singleton +class SuggestionService + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val cache: SuggestionsCache, + private val workManager: WorkManager, + ) { + @OptIn(ExperimentalCoroutinesApi::class) + fun getSuggestionsFlow( + parentId: UUID, + itemKind: BaseItemKind, + ): Flow { + return serverRepository.currentUser + .asFlow() + .flatMapLatest { user -> + val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) + + cache.cacheVersion + .map { cache.get(userId, parentId, itemKind)?.ids.orEmpty() } + .distinctUntilChanged() + .flatMapLatest { cachedIds -> + if (cachedIds.isNotEmpty()) { + flow { + try { + emit(SuggestionsResource.Success(fetchItemsByIds(cachedIds, itemKind))) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + emit(SuggestionsResource.Empty) + } + } + } else { + workManager + .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) + .map { workInfos -> + val isActive = + workInfos.any { + it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED + } + if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty + } + } + } + } + } + + private suspend fun fetchItemsByIds( + ids: List, + itemKind: BaseItemKind, + ): List { + val isSeries = itemKind == BaseItemKind.SERIES + val request = + GetItemsRequest( + ids = ids, + fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW), + ) + return GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.from(it, api, isSeries) } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt new file mode 100644 index 00000000..f131b13b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -0,0 +1,216 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.ui.toServerString +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import kotlinx.serialization.json.encodeToStream +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import timber.log.Timber +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Serializable +data class CachedSuggestions( + val ids: List, +) + +@Singleton +class SuggestionsCache + @Inject + constructor( + @param:ApplicationContext private val context: Context, + ) { + private val json = Json { ignoreUnknownKeys = true } + private val _cacheVersion = MutableStateFlow(0L) + val cacheVersion: StateFlow = _cacheVersion.asStateFlow() + + private val memoryCache: MutableMap = + LinkedHashMap(MAX_MEMORY_CACHE_SIZE, 0.75f, true) + + @Volatile + private var diskCacheLoadedUserId: UUID? = null + private val dirtyKeys: MutableSet = mutableSetOf() + private val mutex = Mutex() + + @OptIn(ExperimentalSerializationApi::class) + private fun writeEntryToDisk( + key: String, + cached: CachedSuggestions, + ) { + runCatching { + val suggestionsDir = cacheDir.apply { mkdirs() } + File(suggestionsDir, "$key.json") + .outputStream() + .use { json.encodeToStream(cached, it) } + }.onFailure { Timber.w(it, "Failed to write evicted cache: $key") } + } + + private fun checkForEviction(newKey: String): Pair? { + if (memoryCache.containsKey(newKey) || memoryCache.size < MAX_MEMORY_CACHE_SIZE) { + return null + } + val eldest = memoryCache.entries.firstOrNull() ?: return null + memoryCache.remove(eldest.key) + return if (dirtyKeys.remove(eldest.key)) eldest.key to eldest.value else null + } + + private fun cacheKey( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ) = "${userId.toServerString()}_${libraryId.toServerString()}_${itemKind.serialName}" + + private val cacheDir: File + get() = File(context.cacheDir, "suggestions") + + @OptIn(ExperimentalSerializationApi::class) + private suspend fun loadFromDisk(userId: UUID) { + if (diskCacheLoadedUserId == userId) return + mutex.withLock { + if (diskCacheLoadedUserId == userId) return@withLock + withContext(Dispatchers.IO) { + val suggestionsDir = cacheDir + if (!suggestionsDir.exists()) { + diskCacheLoadedUserId = userId + return@withContext + } + memoryCache.clear() + suggestionsDir + .listFiles { + it.name.startsWith(userId.toServerString()) + }.orEmpty() + .take(MAX_MEMORY_CACHE_SIZE) + .forEach { file -> + runCatching { + val key = file.nameWithoutExtension + val cached = + file + .inputStream() + .use { json.decodeFromStream(it) } + memoryCache[key] = cached + }.onFailure { Timber.w(it, "Failed to read cache file: ${file.name}") } + } + diskCacheLoadedUserId = userId + } + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun get( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ): CachedSuggestions? { + loadFromDisk(userId) + val key = cacheKey(userId, libraryId, itemKind) + memoryCache[key]?.let { return it } + return withContext(Dispatchers.IO) { + runCatching { + File(cacheDir, "$key.json") + .takeIf { it.exists() } + ?.inputStream() + ?.use { + json.decodeFromStream(it) + }?.also { memoryCache[key] = it } + }.onFailure { Timber.w(it, "Failed to read cache: $key") } + .getOrNull() + } + } + + suspend fun put( + userId: UUID, + libraryId: UUID, + itemKind: BaseItemKind, + ids: List, + ) { + val key = cacheKey(userId, libraryId, itemKind) + val cached = CachedSuggestions(ids) + val evictedEntry = + mutex.withLock { + val evicted = checkForEviction(key) + memoryCache[key] = cached + dirtyKeys.add(key) + _cacheVersion.update { it + 1 } + evicted + } + evictedEntry?.let { (evictedKey, evictedValue) -> + withContext(Dispatchers.IO) { + writeEntryToDisk(evictedKey, evictedValue) + } + } + } + + suspend fun isEmpty(): Boolean = + mutex.withLock { + if (memoryCache.isNotEmpty() || dirtyKeys.isNotEmpty()) { + return@withLock false + } + withContext(Dispatchers.IO) { + val files = cacheDir.listFiles() + files == null || files.isEmpty() + } + } + + @OptIn(ExperimentalSerializationApi::class) + suspend fun save() { + val entriesToSave = + mutex.withLock { + if (dirtyKeys.isEmpty()) return + val entries = + dirtyKeys.mapNotNull { key -> + memoryCache[key]?.let { key to it } + } + dirtyKeys.clear() + entries + } + + withContext(Dispatchers.IO) { + val suggestionsDir = + cacheDir.apply { + if (!mkdirs() && !exists()) Timber.w("Failed to create suggestions cache directory") + } + entriesToSave.forEach { (key, value) -> + runCatching { + File(suggestionsDir, "$key.json") + .outputStream() + .use { json.encodeToStream(value, it) } + }.onFailure { Timber.w(it, "Failed to write cache: $key") } + } + } + } + + suspend fun clear() { + mutex.withLock { + memoryCache.clear() + dirtyKeys.clear() + _cacheVersion.update { it + 1 } + diskCacheLoadedUserId = null + } + withContext(Dispatchers.IO) { + runCatching { cacheDir.deleteRecursively() } + .onFailure { Timber.w(it, "Failed to clear suggestions cache") } + } + } + + companion object { + private const val MAX_MEMORY_CACHE_SIZE = 8 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt new file mode 100644 index 00000000..1b2132fc --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -0,0 +1,100 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.workDataOf +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.toJavaDuration + +@ActivityScoped +class SuggestionsSchedulerService + @Inject + constructor( + @param:ActivityContext private val context: Context, + private val serverRepository: ServerRepository, + private val cache: SuggestionsCache, + private val workManager: WorkManager, + ) { + private val activity = + (context as? AppCompatActivity) + ?: throw IllegalStateException( + "SuggestionsSchedulerService requires an AppCompatActivity context, but received: ${context::class.java.name}", + ) + + // Exposed for testing + internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + + init { + serverRepository.current.observe(activity) { user -> + Timber.v("New user %s", user?.user?.id) + if (user == null) { + workManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) + } else { + activity.lifecycleScope.launch(dispatcher + ExceptionHandler()) { + scheduleWork(user.user.id, user.server.id) + } + } + } + } + + private suspend fun scheduleWork( + userId: UUID, + serverId: UUID, + ) { + val constraints = + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val inputData = + workDataOf( + SuggestionsWorker.PARAM_USER_ID to userId.toString(), + SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), + ) + + if (cache.isEmpty()) { + Timber.i("Suggestions cache empty, scheduling immediate fetch") + workManager.enqueue( + OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData(inputData) + .build(), + ) + } + + Timber.i("Scheduling periodic SuggestionsWorker") + workManager.enqueueUniquePeriodicWork( + uniqueWorkName = SuggestionsWorker.WORK_NAME, + existingPeriodicWorkPolicy = ExistingPeriodicWorkPolicy.UPDATE, + request = + PeriodicWorkRequestBuilder( + repeatInterval = 12.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + .build(), + ) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt new file mode 100644 index 00000000..70943f83 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -0,0 +1,230 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +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.util.GetItemsRequestHandler +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.supervisorScope +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ItemFields +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import timber.log.Timber +import java.util.UUID + +private val BaseItemDto.relevantId: UUID get() = seriesId ?: id + +@HiltWorker +class SuggestionsWorker + @AssistedInject + constructor( + @Assisted private val context: Context, + @Assisted workerParams: WorkerParameters, + private val serverRepository: ServerRepository, + private val preferences: DataStore, + private val api: ApiClient, + private val cache: SuggestionsCache, + ) : CoroutineWorker(context, workerParams) { + override suspend fun doWork(): Result { + Timber.d("Start") + val serverId = inputData.getString(PARAM_SERVER_ID)?.toUUIDOrNull() ?: return Result.failure() + val userId = inputData.getString(PARAM_USER_ID)?.toUUIDOrNull() ?: return Result.failure() + + if (api.baseUrl.isNullOrBlank() || api.accessToken.isNullOrBlank()) { + var currentUser = serverRepository.current.value + if (currentUser == null) { + serverRepository.restoreSession(serverId, userId) + currentUser = serverRepository.current.value + } + if (currentUser == null) { + Timber.w("No user found during run") + return Result.failure() + } + } + + try { + val prefs = preferences.data.firstOrNull() ?: AppPreferences.getDefaultInstance() + val itemsPerRow = + prefs.homePagePreferences.maxItemsPerRow + .takeIf { it > 0 } + ?: AppPreference.HomePageItems.defaultValue.toInt() + + val views = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + .orEmpty() + if (views.isEmpty()) { + return Result.success() + } + val results = + supervisorScope { + views + .mapNotNull { view -> + val itemKind = + when (view.collectionType) { + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + else -> return@mapNotNull null + } + async(Dispatchers.IO) { + runCatching { + Timber.v("Fetching suggestions for view %s", view.id) + val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow) + ensureActive() + cache.put( + userId, + view.id, + itemKind, + suggestions.map { it.id }, + ) + }.onFailure { e -> + Timber.e( + e, + "Failed to fetch suggestions for view %s", + view.id, + ) + } + } + }.awaitAll() + } + val successCount = results.count { it.isSuccess } + val failureCount = results.count { it.isFailure } + cache.save() + if (failureCount > 0 && successCount == 0) { + Timber.w("All attempts failed ($failureCount views), scheduling retry") + return Result.retry() + } + Timber.d("Completed with $successCount successes and $failureCount failures") + return Result.success() + } catch (ex: ApiClientException) { + Timber.w(ex, "SuggestionsWorker ApiClientException, will retry") + return Result.retry() + } catch (e: Exception) { + Timber.e(e, "SuggestionsWorker failed") + return Result.failure() + } + } + + private suspend fun fetchSuggestions( + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + itemsPerRow: Int, + ): List = + coroutineScope { + val isSeries = itemKind == BaseItemKind.SERIES + val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind + + val historyDeferred = + async(Dispatchers.IO) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = historyItemType, + sortBy = ItemSortBy.DATE_PLAYED, + isPlayed = true, + limit = 10, + extraFields = listOf(ItemFields.GENRES), + ).distinctBy { it.relevantId }.take(3) + } + + val randomDeferred = + async(Dispatchers.IO) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.RANDOM, + isPlayed = false, + limit = itemsPerRow, + ) + } + + val freshDeferred = + async(Dispatchers.IO) { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.DATE_CREATED, + sortOrder = SortOrder.DESCENDING, + isPlayed = false, + limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1), + ) + } + + val seedItems = historyDeferred.await() + val random = randomDeferred.await() + val fresh = freshDeferred.await() + + val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } + + (fresh + random) + .asSequence() + .distinctBy { it.id } + .filterNot { excludeIds.contains(it.relevantId) } + .toList() + .shuffled() + .take(itemsPerRow) + } + + private suspend fun fetchItems( + parentId: UUID, + userId: UUID, + itemKind: BaseItemKind, + sortBy: ItemSortBy, + isPlayed: Boolean, + limit: Int, + sortOrder: SortOrder? = null, + genreIds: List? = null, + extraFields: List = emptyList(), + ): List { + val request = + GetItemsRequest( + parentId = parentId, + userId = userId, + fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, + includeItemTypes = listOf(itemKind), + genreIds = genreIds, + recursive = true, + isPlayed = isPlayed, + sortBy = listOf(sortBy), + sortOrder = sortOrder?.let { listOf(it) }, + limit = limit, + enableTotalRecordCount = false, + imageTypeLimit = 1, + ) + return GetItemsRequestHandler + .execute(api, request) + .content.items + .orEmpty() + } + + companion object { + const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" + const val PARAM_USER_ID = "userId" + const val PARAM_SERVER_ID = "serverId" + private const val FRESH_CONTENT_RATIO = 0.4 + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt index e6ae832a..f207562c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/hilt/AppModule.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services.hilt import android.content.Context import androidx.datastore.core.DataStore +import androidx.work.WorkManager import com.github.damontecres.wholphin.BuildConfig import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ServerRepository @@ -176,6 +177,12 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = WorkManager.getInstance(context) + @Provides @Singleton fun seerrApi( diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt index 05ce1d2a..ab0e04bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderSchedulerService.kt @@ -29,9 +29,9 @@ class TvProviderSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, + private val workManager: WorkManager, ) { private val activity = (context as AppCompatActivity) - private val workManager = WorkManager.getInstance(context) private val supportsTvProvider = // TODO <=25 has limited support diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt index 1272faf5..866efbf3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedMovie.kt @@ -15,6 +15,8 @@ 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.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -22,7 +24,6 @@ import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -42,7 +43,6 @@ import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -54,6 +54,7 @@ class RecommendedMovieViewModel private val api: ApiClient, private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -115,7 +116,7 @@ class RecommendedMovieViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -128,13 +129,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -147,27 +146,11 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, false) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.MOVIE), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -181,9 +164,48 @@ class RecommendedMovieViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, false) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.MOVIE) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt index fa4071b6..2decd21a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedTvShow.kt @@ -15,6 +15,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SuggestionService +import com.github.damontecres.wholphin.services.SuggestionsResource import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.setValueOnMain @@ -23,7 +25,6 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetNextUpRequestHandler import com.github.damontecres.wholphin.util.GetResumeItemsRequestHandler -import com.github.damontecres.wholphin.util.GetSuggestionsRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import dagger.assisted.Assisted @@ -45,7 +46,6 @@ import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest -import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -58,6 +58,7 @@ class RecommendedTvShowViewModel private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, private val lastestNextUpService: LatestNextUpService, + private val suggestionService: SuggestionService, @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, @@ -86,7 +87,7 @@ class RecommendedTvShowViewModel val userId = serverRepository.currentUser.value?.id try { val resumeItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val resumeItemsRequest = GetResumeItemsRequest( userId = userId, @@ -104,7 +105,7 @@ class RecommendedTvShowViewModel } val nextUpItemsDeferred = - viewModelScope.async(Dispatchers.IO) { + async(Dispatchers.IO) { val nextUpRequest = GetNextUpRequest( userId = userId, @@ -116,19 +117,16 @@ class RecommendedTvShowViewModel enableUserData = true, enableRewatching = preferences.homePagePreferences.enableRewatchingNextUp, ) - GetNextUpRequestHandler .execute(api, nextUpRequest) .toBaseItems(api, true) } + val resumeItems = resumeItemsDeferred.await() val nextUpItems = nextUpItemsDeferred.await() + if (combineNextUp) { - val combined = - lastestNextUpService.buildCombined( - resumeItems, - nextUpItems, - ) + val combined = lastestNextUpService.buildCombined(resumeItems, nextUpItems) update( R.string.continue_watching, HomeRowLoadingState.Success( @@ -138,10 +136,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - listOf(), - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), listOf()), ) } else { update( @@ -153,10 +148,7 @@ class RecommendedTvShowViewModel ) update( R.string.next_up, - HomeRowLoadingState.Success( - context.getString(R.string.next_up), - nextUpItems, - ), + HomeRowLoadingState.Success(context.getString(R.string.next_up), nextUpItems), ) } @@ -171,7 +163,7 @@ class RecommendedTvShowViewModel } update(R.string.recently_released) { - val recentlyReleasedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -184,14 +176,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyReleasedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.recently_added) { - val recentlyAddedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -204,29 +193,11 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - - GetItemsRequestHandler - .execute(api, recentlyAddedRequest) - .toBaseItems(api, true) - } - - update(R.string.suggestions) { - val suggestionsRequest = - GetSuggestionsRequest( - userId = serverRepository.currentUser.value?.id, - type = listOf(BaseItemKind.SERIES), - startIndex = 0, - limit = itemsPerRow, - enableTotalRecordCount = false, - ) - - GetSuggestionsRequestHandler - .execute(api, suggestionsRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) } update(R.string.top_unwatched) { - val unwatchedTopRatedRequest = + val request = GetItemsRequest( parentId = parentId, fields = SlimItemFields, @@ -240,9 +211,48 @@ class RecommendedTvShowViewModel limit = itemsPerRow, enableTotalRecordCount = false, ) - GetItemsRequestHandler - .execute(api, unwatchedTopRatedRequest) - .toBaseItems(api, true) + GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) + } + + viewModelScope.launch(Dispatchers.IO) { + try { + suggestionService + .getSuggestionsFlow(parentId, BaseItemKind.SERIES) + .collect { resource -> + val state = + when (resource) { + is SuggestionsResource.Loading -> { + HomeRowLoadingState.Loading( + context.getString(R.string.suggestions), + ) + } + + is SuggestionsResource.Success -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + resource.items, + ) + } + + is SuggestionsResource.Empty -> { + HomeRowLoadingState.Success( + context.getString(R.string.suggestions), + emptyList(), + ) + } + } + update(R.string.suggestions, state) + } + } catch (ex: Exception) { + Timber.e(ex, "Failed to fetch suggestions") + update( + R.string.suggestions, + HomeRowLoadingState.Error( + title = context.getString(R.string.suggestions), + exception = ex, + ), + ) + } } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt new file mode 100644 index 00000000..2f858ab1 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -0,0 +1,261 @@ +package com.github.damontecres.wholphin.services + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.Response +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestionServiceTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockApi = mockk(relaxed = true) + private val mockServerRepository = mockk() + private val mockCache = mockk() + private val mockWorkManager = mockk() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + io.mockk.unmockkObject(GetItemsRequestHandler) + } + + private fun createService() = + SuggestionService( + api = mockApi, + serverRepository = mockServerRepository, + cache = mockCache, + workManager = mockWorkManager, + ) + + private fun mockQueryResult(items: List): Response = + mockk { + every { content } returns + mockk { + every { this@mockk.items } returns items + } + } + + private fun mockUser(id: UUID = UUID.randomUUID()): JellyfinUser = + JellyfinUser( + id = id, + name = "TestUser", + serverId = UUID.randomUUID(), + accessToken = "token", + ) + + private fun mockWorkInfo(state: WorkInfo.State): WorkInfo = mockk { every { this@mockk.state } returns state } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenNoUserLoggedIn() = + runTest { + val currentUser = MutableLiveData(null) + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(UUID.randomUUID(), BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + + @Test + fun maps_active_work_states_to_Loading() = + runTest { + listOf(WorkInfo.State.RUNNING, WorkInfo.State.ENQUEUED).forEach { state -> + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(state))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Loading, result) + } + } + + @Test + fun maps_finished_work_states_to_Empty() = + runTest { + listOf(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED, WorkInfo.State.CANCELLED).forEach { state -> + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(state))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + } + + @Test + fun getSuggestionsFlow_returnsEmpty_whenCacheEmptyAndNoWorkInfo() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } + + @Test + fun passes_correct_arguments_to_cache() = + runTest { + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val otherLibraryId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null + coEvery { + mockCache.get( + userId, + otherLibraryId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(UUID.randomUUID())) + + val service = createService() + assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) + + coEvery { mockCache.get(userId, libraryId, BaseItemKind.MOVIE) } returns null + coEvery { + mockCache.get( + userId, + libraryId, + BaseItemKind.SERIES, + ) + } returns CachedSuggestions(listOf(UUID.randomUUID())) + + assertEquals(SuggestionsResource.Empty, service.getSuggestionsFlow(libraryId, BaseItemKind.MOVIE).first()) + } + + @Test + fun getSuggestionsFlow_returnsSuccess_whenCacheHasItems() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + + val cachedId = UUID.randomUUID() + coEvery { + mockCache.get( + userId, + parentId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(cachedId)) + + val dto = + mockk(relaxed = true) { + every { id } returns cachedId + every { type } returns BaseItemKind.MOVIE + } + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(dto)) + + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertTrue(result is SuggestionsResource.Success) + val items = (result as SuggestionsResource.Success).items + assertEquals(1, items.size) + assertEquals(cachedId, items[0].id) + } + + @Test + fun getSuggestionsFlow_emitsEmpty_whenApiFails() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + every { mockCache.cacheVersion } returns MutableStateFlow(0L) + + val cachedId = UUID.randomUUID() + coEvery { + mockCache.get( + userId, + parentId, + BaseItemKind.MOVIE, + ) + } returns CachedSuggestions(listOf(cachedId)) + + io.mockk.mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } throws RuntimeException("Network error") + + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns flowOf(emptyList()) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt new file mode 100644 index 00000000..709509c6 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -0,0 +1,234 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID +import kotlin.io.path.createTempDirectory + +class SuggestionsCacheTest { + private val tempDir = createTempDirectory("suggestions-cache-test").toFile() + + @After + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun testCacheWithTempDir(): SuggestionsCache { + val mockContext = mockk(relaxed = true) + every { mockContext.cacheDir } returns tempDir + return SuggestionsCache(mockContext) + } + + private fun memoryCacheOf(cache: SuggestionsCache): MutableMap { + val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") + field.isAccessible = true + @Suppress("UNCHECKED_CAST") + return field.get(cache) as MutableMap + } + + @Test + fun putThenGet_returnsCachedSuggestions() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libId = UUID.randomUUID() + + cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + + val loaded = cache.get(userId, libId, BaseItemKind.MOVIE) + assertNotNull(loaded) + assertEquals(0, loaded!!.ids.size) + } + + @Test + fun get_readsFromDisk_whenMemoryAbsent() = + runTest { + val cache1 = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libId = UUID.randomUUID() + + cache1.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + cache1.save() + + // Create a fresh instance which won't have the memory entry + val cache2 = testCacheWithTempDir() + // memoryCache should be empty + assertTrue(memoryCacheOf(cache2).isEmpty()) + + val loaded = cache2.get(userId, libId, BaseItemKind.MOVIE) + assertNotNull(loaded) + assertEquals(0, loaded!!.ids.size) + // After read, memory cache should contain the entry + assertTrue(memoryCacheOf(cache2).isNotEmpty()) + } + + // LRU behavior is not enforced in production; keep tests focused on public behavior. + @Test + fun memoryCache_respectsLruLimit() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + + // Insert MAX + 2 entries and ensure size never exceeds limit + val limit = 8 // keep in sync with implementation + val libIds = mutableListOf() + for (i in 0 until (limit + 2)) { + val libId = UUID.randomUUID() + libIds.add(libId) + cache.put(userId, libId, BaseItemKind.MOVIE, emptyList()) + } + + // memoryCache should be bounded to the limit + val mem = memoryCacheOf(cache) + assertTrue(mem.size <= limit) + // The oldest (first inserted) should be evicted from memory cache + val firstKey = "${userId}_${libIds.first()}_${BaseItemKind.MOVIE.serialName}" + assertFalse(mem.containsKey(firstKey)) + } + + // Library isolation tests - verify different parentIds/itemKinds don't mix + + @Test + fun differentParentIds_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val movieLibraryId = UUID.randomUUID() + val tvLibraryId = UUID.randomUUID() + + val movieIds = List(2) { UUID.randomUUID() } + val tvIds = List(3) { UUID.randomUUID() } + + cache.put(userId, movieLibraryId, BaseItemKind.MOVIE, movieIds) + cache.put(userId, tvLibraryId, BaseItemKind.MOVIE, tvIds) + + val loadedMovies = cache.get(userId, movieLibraryId, BaseItemKind.MOVIE) + val loadedTv = cache.get(userId, tvLibraryId, BaseItemKind.MOVIE) + + assertNotNull(loadedMovies) + assertNotNull(loadedTv) + assertEquals(2, loadedMovies!!.ids.size) + assertEquals(3, loadedTv!!.ids.size) + assertEquals(movieIds[0], loadedMovies.ids[0]) + assertEquals(tvIds[0], loadedTv.ids[0]) + } + + @Test + fun differentItemKinds_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + + val movieIds = listOf(UUID.randomUUID()) + val seriesIds = List(2) { UUID.randomUUID() } + + cache.put(userId, libraryId, BaseItemKind.MOVIE, movieIds) + cache.put(userId, libraryId, BaseItemKind.SERIES, seriesIds) + + val loadedMovies = cache.get(userId, libraryId, BaseItemKind.MOVIE) + val loadedSeries = cache.get(userId, libraryId, BaseItemKind.SERIES) + + assertNotNull(loadedMovies) + assertNotNull(loadedSeries) + assertEquals(1, loadedMovies!!.ids.size) + assertEquals(2, loadedSeries!!.ids.size) + assertEquals(movieIds[0], loadedMovies.ids[0]) + assertEquals(seriesIds[0], loadedSeries.ids[0]) + } + + @Test + fun rapidLibrarySwitching_maintainsIsolation() = + runTest { + val cache = testCacheWithTempDir() + val userId = UUID.randomUUID() + val lib1 = UUID.randomUUID() + val lib2 = UUID.randomUUID() + val lib3 = UUID.randomUUID() + + val ids1 = listOf(UUID.randomUUID()) + val ids2 = listOf(UUID.randomUUID()) + val ids3 = listOf(UUID.randomUUID()) + + // Simulate rapid switching: put -> get -> put -> get pattern + cache.put(userId, lib1, BaseItemKind.MOVIE, ids1) + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + cache.put(userId, lib2, BaseItemKind.MOVIE, ids2) + assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + // Switch back to lib1 - should still have correct data + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + cache.put(userId, lib3, BaseItemKind.MOVIE, ids3) + assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + + // Verify all libraries still have correct data after rapid switching + assertEquals(ids1[0], cache.get(userId, lib1, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + assertEquals(ids2[0], cache.get(userId, lib2, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + assertEquals(ids3[0], cache.get(userId, lib3, BaseItemKind.MOVIE)?.ids?.firstOrNull()) + } + + @Test + fun libraryIsolation_persistsToDisk() = + runTest { + val userId = UUID.randomUUID() + val lib1 = UUID.randomUUID() + val lib2 = UUID.randomUUID() + + val ids1 = listOf(UUID.randomUUID()) + val ids2 = listOf(UUID.randomUUID()) + + // Write with first cache instance + val cache1 = testCacheWithTempDir() + cache1.put(userId, lib1, BaseItemKind.MOVIE, ids1) + cache1.put(userId, lib2, BaseItemKind.SERIES, ids2) + cache1.save() + + // Read with fresh cache instance (empty memory cache, reads from disk) + val cache2 = testCacheWithTempDir() + assertTrue(memoryCacheOf(cache2).isEmpty()) + + val loaded1 = cache2.get(userId, lib1, BaseItemKind.MOVIE) + val loaded2 = cache2.get(userId, lib2, BaseItemKind.SERIES) + + assertNotNull(loaded1) + assertNotNull(loaded2) + assertEquals(ids1[0], loaded1!!.ids[0]) + assertEquals(ids2[0], loaded2!!.ids[0]) + } + + @Test + fun differentUsers_returnIsolatedCacheEntries() = + runTest { + val cache = testCacheWithTempDir() + val user1 = UUID.randomUUID() + val user2 = UUID.randomUUID() + val libraryId = UUID.randomUUID() + + val user1Ids = listOf(UUID.randomUUID()) + val user2Ids = List(2) { UUID.randomUUID() } + + cache.put(user1, libraryId, BaseItemKind.MOVIE, user1Ids) + cache.put(user2, libraryId, BaseItemKind.MOVIE, user2Ids) + + val loadedUser1 = cache.get(user1, libraryId, BaseItemKind.MOVIE) + val loadedUser2 = cache.get(user2, libraryId, BaseItemKind.MOVIE) + + assertNotNull(loadedUser1) + assertNotNull(loadedUser2) + assertEquals(1, loadedUser1!!.ids.size) + assertEquals(2, loadedUser2!!.ids.size) + assertEquals(user1Ids[0], loadedUser1.ids[0]) + assertEquals(user2Ids[0], loadedUser2.ids[0]) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt new file mode 100644 index 00000000..0df0cf38 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -0,0 +1,90 @@ +package com.github.damontecres.wholphin.services + +import androidx.appcompat.app.AppCompatActivity +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkManager +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 io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID + +@OptIn(ExperimentalCoroutinesApi::class) +class SuggestionsSchedulerServiceTest { + @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() + private val testDispatcher = StandardTestDispatcher() + private val currentLiveData = MutableLiveData() + private val mockActivity = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockWorkManager = mockk(relaxed = true) + private val lifecycleRegistry = LifecycleRegistry(mockk(relaxed = true)) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + every { mockActivity.lifecycle } returns lifecycleRegistry + every { mockServerRepository.current } returns currentLiveData + lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun createService() = + SuggestionsSchedulerService( + context = mockActivity, + serverRepository = mockServerRepository, + cache = mockCache, + workManager = mockWorkManager, + ).also { it.dispatcher = testDispatcher } + + @Test + fun schedules_periodic_work_when_user_present() = + runTest { + coEvery { mockCache.isEmpty() } returns false + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } + } + + @Test + fun cancels_work_when_user_null() = + runTest { + coEvery { mockCache.isEmpty() } returns false + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = UUID.randomUUID(), name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + currentLiveData.value = null + advanceUntilIdle() + verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt new file mode 100644 index 00000000..4e388f21 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -0,0 +1,154 @@ +package com.github.damontecres.wholphin.services + +import androidx.datastore.core.DataStore +import androidx.lifecycle.MutableLiveData +import androidx.work.Data +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import com.github.damontecres.wholphin.data.CurrentUser +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.exception.ApiClientException +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.api.operations.UserViewsApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.UUID + +class SuggestionsWorkerTest { + private val testUserId = UUID.randomUUID() + private val testServerId = UUID.randomUUID() + private val mockWorkerParams = mockk(relaxed = true) + private val mockServerRepository = mockk(relaxed = true) + private val mockPreferences = mockk>(relaxed = true) + private val mockApi = mockk(relaxed = true) + private val mockCache = mockk(relaxed = true) + private val mockUserViewsApi = mockk(relaxed = true) + + @Before + fun setUp() { + every { mockApi.userViewsApi } returns mockUserViewsApi + every { mockApi.baseUrl } returns "http://localhost" + every { mockApi.accessToken } returns "test-token" + } + + @After + fun tearDown() = unmockkObject(GetItemsRequestHandler) + + private fun createWorker( + userId: UUID? = testUserId, + serverId: UUID? = testServerId, + ): SuggestionsWorker { + val inputData = + Data + .Builder() + .apply { + userId?.let { putString(SuggestionsWorker.PARAM_USER_ID, it.toString()) } + serverId?.let { putString(SuggestionsWorker.PARAM_SERVER_ID, it.toString()) } + }.build() + every { mockWorkerParams.inputData } returns inputData + return SuggestionsWorker( + context = mockk(relaxed = true), + workerParams = mockWorkerParams, + serverRepository = mockServerRepository, + preferences = mockPreferences, + api = mockApi, + cache = mockCache, + ) + } + + private fun mockPrefs() = + mockk(relaxed = true) { + every { homePagePreferences } returns mockk { every { maxItemsPerRow } returns 25 } + } + + private fun mockQueryResult(items: List = emptyList()) = + mockk>(relaxed = true) { + every { content } returns mockk { every { this@mockk.items } returns items } + } + + @Test + fun returns_failure_on_invalid_input() = + runTest { + listOf( + createWorker(userId = null), + createWorker(serverId = null), + ).forEach { worker -> + assertEquals(ListenableWorker.Result.failure(), worker.doWork()) + } + } + + @Test + fun restores_session_when_api_not_configured() = + runTest { + every { mockApi.baseUrl } returns null + every { mockApi.accessToken } returns null + val mockUser = mockk() + var restored = false + every { mockServerRepository.current } answers { MutableLiveData(if (restored) mockUser else null) } + coEvery { mockServerRepository.restoreSession(testServerId, testUserId) } coAnswers { + restored = true + mockUser + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult() + + val result = createWorker().doWork() + + coVerify { mockServerRepository.restoreSession(testServerId, testUserId) } + assertEquals(ListenableWorker.Result.success(), result) + } + + @Test + fun caches_suggestions_for_supported_types() = + runTest { + listOf( + CollectionType.MOVIES to BaseItemKind.MOVIE, + CollectionType.TVSHOWS to BaseItemKind.SERIES, + ).forEach { (collectionType, itemKind) -> + val viewId = UUID.randomUUID() + val view = + mockk(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns collectionType + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } returns mockQueryResult(listOf(mockk(relaxed = true))) + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } + coVerify { mockCache.save() } + } + } + + @Test + fun returns_retry_on_network_error() = + runTest { + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } throws ApiClientException("Network error") + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.retry(), result) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt new file mode 100644 index 00000000..5027aee8 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSuggestionsLogic.kt @@ -0,0 +1,295 @@ +package com.github.damontecres.wholphin.test + +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.NameGuidPair +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +/** + * Tests for the suggestions deduplication and genre collection logic used in + * RecommendedMovie and RecommendedTvShow ViewModels. + */ +class TestSuggestionsDeduplication { + @Test + fun `deduplication by seriesId groups episodes of same series`() { + val seriesId = UUID.randomUUID() + val items = + listOf( + episode(seriesId = seriesId, name = "Episode 1"), + episode(seriesId = seriesId, name = "Episode 2"), + episode(seriesId = seriesId, name = "Episode 3"), + movie(name = "Some Movie"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + // Should have 2 items: one series entry and one movie + assertEquals(2, deduplicated.size) + } + + @Test + fun `deduplication preserves order - most recent first`() { + val series1 = UUID.randomUUID() + val series2 = UUID.randomUUID() + val items = + listOf( + episode(seriesId = series1, name = "S1 Episode 1"), // Most recent + episode(seriesId = series1, name = "S1 Episode 2"), + episode(seriesId = series2, name = "S2 Episode 1"), + movie(name = "Old Movie"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + assertEquals(3, deduplicated.size) + // First item should be from series1 (most recent) + assertEquals(series1, deduplicated[0].seriesId) + assertEquals(series2, deduplicated[1].seriesId) + } + + @Test + fun `movies use their own id for deduplication`() { + val items = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + // All movies should be kept since they have unique IDs + assertEquals(3, deduplicated.size) + } + + @Test + fun `take 3 limits seed items`() { + val items = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + movie(name = "Movie 4"), + movie(name = "Movie 5"), + ) + + val deduplicated = + items + .distinctBy { it.seriesId ?: it.id } + .take(3) + + assertEquals(3, deduplicated.size) + } +} + +class TestSuggestionsGenreCollection { + @Test + fun `collects genres from multiple seed items`() { + val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") + val genre2 = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") + val genre3 = NameGuidPair(id = UUID.randomUUID(), name = "Drama") + + val seedItems = + listOf( + movie(name = "Action Movie", genres = listOf(genre1)), + movie(name = "Comedy Movie", genres = listOf(genre2)), + movie(name = "Drama Movie", genres = listOf(genre3)), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertEquals(3, allGenreIds.size) + assertTrue(allGenreIds.contains(genre1.id)) + assertTrue(allGenreIds.contains(genre2.id)) + assertTrue(allGenreIds.contains(genre3.id)) + } + + @Test + fun `deduplicates shared genres across seed items`() { + val sharedGenre = NameGuidPair(id = UUID.randomUUID(), name = "Action") + val uniqueGenre = NameGuidPair(id = UUID.randomUUID(), name = "Comedy") + + val seedItems = + listOf( + movie(name = "Action Movie 1", genres = listOf(sharedGenre)), + movie(name = "Action Movie 2", genres = listOf(sharedGenre)), + movie(name = "Action Comedy", genres = listOf(sharedGenre, uniqueGenre)), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + // Should only have 2 unique genres + assertEquals(2, allGenreIds.size) + } + + @Test + fun `handles items with no genres`() { + val genre1 = NameGuidPair(id = UUID.randomUUID(), name = "Action") + + val seedItems = + listOf( + movie(name = "Movie with genre", genres = listOf(genre1)), + movie(name = "Movie without genre", genres = null), + movie(name = "Movie with empty genres", genres = emptyList()), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertEquals(1, allGenreIds.size) + assertEquals(genre1.id, allGenreIds[0]) + } + + @Test + fun `returns empty list when no seed items have genres`() { + val seedItems = + listOf( + movie(name = "Movie 1", genres = null), + movie(name = "Movie 2", genres = emptyList()), + ) + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + assertTrue(allGenreIds.isEmpty()) + } +} + +class TestSuggestionsExcludeIds { + @Test + fun `excludeIds contains all seed item ids`() { + val seedItems = + listOf( + movie(name = "Movie 1"), + movie(name = "Movie 2"), + movie(name = "Movie 3"), + ) + + val excludeIds = seedItems.map { it.id } + + assertEquals(3, excludeIds.size) + seedItems.forEach { item -> + assertTrue(excludeIds.contains(item.id)) + } + } +} + +@RunWith(Parameterized::class) +class TestSuggestionsCombineAndDeduplicate( + private val contextual: List, + private val random: List, + private val fresh: List, + private val expectedUniqueCount: Int, + private val description: String, +) { + @Test + fun `combine and deduplicate works correctly`() { + val combined = + (contextual + random + fresh) + .distinctBy { it.id } + + assertEquals(description, expectedUniqueCount, combined.size) + } + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{4}") + fun data(): Collection> { + val movie1 = movie(name = "Movie 1") + val movie2 = movie(name = "Movie 2") + val movie3 = movie(name = "Movie 3") + val movie4 = movie(name = "Movie 4") + + return listOf( + arrayOf( + listOf(movie1, movie2), + listOf(movie3), + listOf(movie4), + 4, + "no duplicates - all 4 unique", + ), + arrayOf( + listOf(movie1, movie2), + listOf(movie1, movie3), + listOf(movie2, movie4), + 4, + "with duplicates - deduplicates to 4", + ), + arrayOf( + listOf(movie1), + listOf(movie1), + listOf(movie1), + 1, + "all same - deduplicates to 1", + ), + arrayOf( + emptyList(), + listOf(movie1, movie2), + listOf(movie3), + 3, + "empty contextual - still combines others", + ), + arrayOf( + emptyList(), + emptyList(), + emptyList(), + 0, + "all empty - returns empty", + ), + ) + } + } +} + +// Helper functions to create test data + +private fun movie( + id: UUID = UUID.randomUUID(), + name: String = "Test Movie", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.MOVIE, + name = name, + seriesId = null, + genreItems = genres, + ) + +private fun episode( + id: UUID = UUID.randomUUID(), + seriesId: UUID, + name: String = "Test Episode", + genres: List? = null, +): BaseItemDto = + BaseItemDto( + id = id, + type = BaseItemKind.EPISODE, + name = name, + seriesId = seriesId, + genreItems = genres, + ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92e42737..b2a32580 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" +kotlinxCoroutinesTest = "1.7.3" +coreTesting = "2.2.0" openapi-generator = "7.19.0" [libraries] @@ -67,6 +69,7 @@ androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-com androidx-tv-foundation = { group = "androidx.tv", name = "tv-foundation", version.ref = "tvFoundation" } androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycleRuntimeKtx" } 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" } @@ -124,6 +127,8 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-preference-ktx = { group = "androidx.preference", name = "preference-ktx", version.ref = "preferenceKtx" } androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }