From 733213d6a21280fc658f946e19718e9db954d76d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:30:24 -0500 Subject: [PATCH 001/176] Simplify how display mode is changed (#823) ## Description Simplifies the refresh rate/display mode change code by removing the live data go between. Also, instead of a `CountDownLatch` that blocks the thread, use a `Deferred` to suspend the coroutine instead. ### Related issues Might help with #769 ### Testing Tested on nvidia shield --- .../damontecres/wholphin/MainActivity.kt | 25 ++++++++---- .../wholphin/services/RefreshRateService.kt | 39 ++++++++++--------- 2 files changed, 37 insertions(+), 27 deletions(-) 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 0f8b7ee4..af88392e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -66,9 +66,12 @@ import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree +import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -126,19 +129,12 @@ class MainActivity : AppCompatActivity() { @OptIn(ExperimentalTvMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + instance = this Timber.i("MainActivity.onCreate: savedInstanceState is null=${savedInstanceState == null}") lifecycle.addObserver(playbackLifecycleObserver) if (savedInstanceState == null) { appUpgradeHandler.copySubfont(false) } - refreshRateService.refreshRateMode.observe(this) { modeId -> - // Listen for refresh rate changes - val attrs = window.attributes - if (attrs.preferredDisplayModeId != modeId) { - Timber.d("Switch preferredDisplayModeId to %s", modeId) - window.attributes = attrs.apply { preferredDisplayModeId = modeId } - } - } viewModel.serverRepository.currentUser.observe(this) { user -> if (user?.hasPin == true) { window?.setFlags( @@ -384,6 +380,16 @@ class MainActivity : AppCompatActivity() { } } + fun changeDisplayMode(modeId: Int) { + lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) { + val attrs = window.attributes + if (attrs.preferredDisplayModeId != modeId) { + Timber.d("Switch preferredDisplayModeId to %s", modeId) + window.attributes = attrs.apply { preferredDisplayModeId = modeId } + } + } + } + companion object { const val INTENT_ITEM_ID = "itemId" const val INTENT_ITEM_TYPE = "itemType" @@ -391,6 +397,9 @@ class MainActivity : AppCompatActivity() { const val INTENT_EPISODE_NUMBER = "epNum" const val INTENT_SEASON_NUMBER = "seaNum" const val INTENT_SEASON_ID = "seaId" + + lateinit var instance: MainActivity + private set } } 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 be6e461e..d961d4c5 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 @@ -6,17 +6,17 @@ import android.os.Build import android.os.Handler import android.os.Looper import android.view.Display -import androidx.lifecycle.LiveData -import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.ui.showToast -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jellyfin.sdk.model.api.MediaStream import org.jellyfin.sdk.model.api.MediaStreamType import timber.log.Timber -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToInt @@ -30,7 +30,6 @@ class RefreshRateService ) { private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager private val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - private val originalMode = display.mode val supportedDisplayModes get() = display.supportedModes.orEmpty() @@ -44,9 +43,6 @@ class RefreshRateService ) } - private val _refreshRateMode = EqualityMutableLiveData(originalMode.modeId) - val refreshRateMode: LiveData = _refreshRateMode - /** * Find the best display mode for the given stream and signal to change to it */ @@ -54,10 +50,10 @@ class RefreshRateService stream: MediaStream, switchRefreshRate: Boolean, switchResolution: Boolean, - ) { + ) = withContext(Dispatchers.IO) { if (!switchRefreshRate && !switchResolution) { Timber.v("Not switching either refresh rate nor resolution") - return + return@withContext } val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } @@ -67,7 +63,7 @@ class RefreshRateService if (switchRefreshRate) stream.realFrameRate else currentDisplayMode.refreshRate if (width == null || height == null || frameRate == null) { Timber.w("Video stream missing required info: width=%s, height=%s, frameRate=%s", width, height, frameRate) - return + return@withContext } Timber.d("Getting refresh rate for: width=%s, height=%s, frameRate=%s", width, height, frameRate) val targetMode = @@ -86,14 +82,20 @@ class RefreshRateService listener, Handler(Looper.myLooper() ?: Looper.getMainLooper()), ) - _refreshRateMode.setValueOnMain(targetMode.modeId) try { - if (!listener.latch.await(5, TimeUnit.SECONDS)) { + MainActivity.instance.changeDisplayMode(targetMode.modeId) + val result = + withTimeoutOrNull(5.seconds) { + listener.deferred.await() + } + if (result == null) { Timber.w("Timed out waiting for display change") showToast(context, "Refresh rate switch is taking a long time") } - } catch (ex: InterruptedException) { + } catch (ex: Exception) { Timber.w(ex, "Exception waiting for refresh rate switch") + } finally { + displayManager.unregisterDisplayListener(listener) } val targetRate = (targetMode.refreshRate * 1000).roundToInt() val isSeamless = @@ -110,7 +112,6 @@ class RefreshRateService // Wait the recommended 2 seconds (https://developer.android.com/media/optimize/performance/frame-rate) delay(2.seconds) } - displayManager.unregisterDisplayListener(listener) } } @@ -118,13 +119,13 @@ class RefreshRateService * Reset the display mode to the original */ fun resetRefreshRate() { - _refreshRateMode.value = originalMode.modeId + MainActivity.instance.changeDisplayMode(0) } private class Listener( val displayId: Int, ) : DisplayManager.DisplayListener { - val latch = CountDownLatch(1) + val deferred = CompletableDeferred() override fun onDisplayAdded(displayId: Int) { } @@ -132,7 +133,7 @@ class RefreshRateService override fun onDisplayChanged(displayId: Int) { if (displayId == this.displayId) { Timber.v("Got display change for $displayId") - latch.countDown() + deferred.complete(Unit) } } From b0b8ae3639979f87758530fd4b8e49f8cee14822 Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:55:05 +1100 Subject: [PATCH 002/176] Align typography and placement between Home, MovieDetails and SeriesDetails (#751) ## Description This PR addresses some quick changes to align the styling and placement of elements on the Home, Movie details and Series details pages, using Home as the reference point for the typography. ### Related issues Ongoing discussion [here](https://github.com/damontecres/Wholphin/discussions/704) ### Screenshots Movie Details Screenshot_20260123_223116 Series Details Screenshot_20260123_223207 ### AI/LLM usage LLM was only used to identify the current styling applied to the pages. All code changes were made and reviewed manually --- .../wholphin/ui/cards/ChapterRow.kt | 2 +- .../damontecres/wholphin/ui/cards/ItemRow.kt | 2 ++ .../wholphin/ui/cards/PersonRow.kt | 2 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 4 ++-- .../ui/detail/movie/MovieDetailsHeader.kt | 24 ++++++++++--------- .../ui/detail/series/SeriesDetails.kt | 23 +++++++++++------- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- 7 files changed, 34 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt index cf799c54..b1e64a2a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ChapterRow.kt @@ -34,6 +34,7 @@ fun ChapterRow( text = stringResource(R.string.chapters), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -41,7 +42,6 @@ fun ChapterRow( contentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 83166fa7..314a7e1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,6 +58,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt index 2895db83..140cc2fd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonRow.kt @@ -46,6 +46,7 @@ fun PersonRow( text = stringResource(title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = rememberLazyListState(), @@ -53,7 +54,6 @@ fun PersonRow( contentPadding = PaddingValues(8.dp), modifier = Modifier - .padding(start = 16.dp) .fillMaxWidth() .focusRestorer(firstFocus), ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 58e7762d..b4c4584d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -410,7 +410,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { @@ -430,7 +430,7 @@ fun MovieDetailsContent( modifier = Modifier .fillMaxWidth() - .padding(top = 32.dp, bottom = 16.dp), + .padding(top = 40.dp, bottom = 16.dp), ) ExpandablePlayButtons( resumePosition = resumePosition, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 60451804..4d5f72d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -46,46 +46,47 @@ fun MovieDetailsHeader( val context = LocalContext.current val scope = rememberCoroutineScope() Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { // Title Text( text = movie.name ?: "", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.displaySmall, - fontWeight = FontWeight.SemiBold, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - val padding = 4.dp QuickDetails( movie.ui.quickDetails, movie.timeRemainingOrRuntime, - Modifier.padding(bottom = padding), + Modifier.padding(start = 8.dp), ) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(bottom = padding)) + GenreText(it, Modifier.padding(start = 8.dp)) } VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = movie.data.mediaSourceCount ?: 0, - modifier = Modifier.padding(bottom = padding), + modifier = Modifier.padding(start = 8.dp, top = 4.dp, bottom = 16.dp), ) dto.taglines?.firstOrNull()?.let { tagline -> Text( text = tagline, style = MaterialTheme.typography.bodyLarge, fontStyle = FontStyle.Italic, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } @@ -120,6 +121,7 @@ fun MovieDetailsHeader( text = stringResource(R.string.directed_by, it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 8.dp), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 305c6451..1db5bb61 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -332,12 +333,12 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(16.dp) + .padding(horizontal = 24.dp, vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( contentPadding = PaddingValues(bottom = 80.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier, ) { item { @@ -354,7 +355,7 @@ fun SeriesDetailsContent( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(start = 16.dp) + .padding(start = 8.dp) .focusRequester(focusRequesters[HEADER_ROW]) .focusRestorer(playFocusRequester) .focusGroup() @@ -599,23 +600,27 @@ fun SeriesDetailsHeader( val scope = rememberCoroutineScope() val dto = series.data Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, ) { Text( text = series.name ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.displaySmall, - maxLines = 2, + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.headlineMedium.copy(fontWeight = FontWeight.SemiBold), + maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(.75f), + modifier = + Modifier + .fillMaxWidth(.75f) + .padding(start = 8.dp), ) Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth(.60f), ) { - QuickDetails(series.ui.quickDetails, null) + QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) } dto.overview?.let { overview -> OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 4c58b00c..0e6e172b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -244,7 +244,7 @@ fun HomePageContent( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues( - start = 16.dp, + start = 24.dp, end = 16.dp, top = 0.dp, bottom = Cards.height2x3, 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 003/176] 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 004/176] 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 005/176] 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" } From abf5ec8004facf4ce688af1e94c8e7e42526416d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 7 Feb 2026 13:03:10 -0500 Subject: [PATCH 006/176] Take playback speed into account when calculating the video end time (#843) ## Description Small change so that the "Ends" time on the playback overlay takes the current playback speed into account. ### Related issues Closes #836 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/playback/PlaybackOverlay.kt | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 99313af2..ac6aaeed 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -555,16 +555,19 @@ fun Controller( ) } if (showClock) { - var remaining by remember { mutableStateOf((playerControls.duration - playerControls.currentPosition).milliseconds) } + var endTimeStr by remember { mutableStateOf("...") } LaunchedEffect(playerControls) { while (isActive) { - remaining = - (playerControls.duration - playerControls.currentPosition).milliseconds + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) delay(1.seconds) } } - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - val endTimeStr = TimeFormatter.format(endTime) Text( text = "Ends $endTimeStr", color = MaterialTheme.colorScheme.onSurface, From 8e9a6799f5cce0a16cc8d90f819e846cdf3b4025 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Sat, 7 Feb 2026 19:38:26 -0600 Subject: [PATCH 007/176] Fix: suggestions logic/re-implement logic into worker and address performance issues (#834) ## Description Addresses suggestion logic oversight in recently merged suggestions fix, and improve performance (especially app homepage startup time) - Re-implements the logic that sorts/categorizes tailored suggestions based on the seed items that are queried. This was implemented in the original PR at first, but I overlooked moving that logic - Removes `OneTimeWorkRequestBuilder` and replaces with trigger in `PeriodicWorkRequest` to fire with a 30 seconds delay if cache is empty. ### Related issues Fixes #833 ### Testing Will be testing in: Android TV emulator in Android Studio NVIDIA Shield TV Pro 2019 ## AI or LLM usage I will use AI to help with writing the tests/testing services. --- .../services/SuggestionsSchedulerService.kt | 33 ++++---- .../wholphin/services/SuggestionsWorker.kt | 46 +++++++++-- .../SuggestionsSchedulerServiceTest.kt | 54 +++++++++++++ .../services/SuggestionsWorkerTest.kt | 79 +++++++++++++++++++ 4 files changed, 186 insertions(+), 26 deletions(-) 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 index 1b2132fc..42d59dae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -7,7 +7,6 @@ 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 @@ -23,6 +22,7 @@ import java.util.UUID import javax.inject.Inject import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @ActivityScoped @@ -72,29 +72,26 @@ class SuggestionsSchedulerService SuggestionsWorker.PARAM_SERVER_ID to serverId.toString(), ) + val periodicWorkRequestBuilder = + PeriodicWorkRequestBuilder( + repeatInterval = 12.hours.toJavaDuration(), + ).setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 15.minutes.toJavaDuration(), + ).setInputData(inputData) + if (cache.isEmpty()) { - Timber.i("Suggestions cache empty, scheduling immediate fetch") - workManager.enqueue( - OneTimeWorkRequestBuilder() - .setConstraints(constraints) - .setInputData(inputData) - .build(), - ) + Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay") + periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration()) + } else { + Timber.i("Scheduling periodic SuggestionsWorker") } - 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(), + request = periodicWorkRequestBuilder.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 index 70943f83..405947c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -136,6 +136,10 @@ class SuggestionsWorker val isSeries = itemKind == BaseItemKind.SERIES val historyItemType = if (isSeries) BaseItemKind.EPISODE else itemKind + val contextualLimit = (itemsPerRow * 0.4).toInt().coerceAtLeast(1) + val randomLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) + val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) + val historyDeferred = async(Dispatchers.IO) { fetchItems( @@ -144,11 +148,38 @@ class SuggestionsWorker itemKind = historyItemType, sortBy = ItemSortBy.DATE_PLAYED, isPlayed = true, - limit = 10, + limit = 20, extraFields = listOf(ItemFields.GENRES), ).distinctBy { it.relevantId }.take(3) } + val seedItems = historyDeferred.await() + + val allGenreIds = + seedItems + .flatMap { it.genreItems?.mapNotNull { g -> g.id } ?: emptyList() } + .distinct() + + val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } + + val contextualDeferred = + async(Dispatchers.IO) { + if (allGenreIds.isEmpty()) { + emptyList() + } else { + fetchItems( + parentId = parentId, + userId = userId, + itemKind = itemKind, + sortBy = ItemSortBy.RANDOM, + isPlayed = false, + limit = contextualLimit, + genreIds = allGenreIds, + excludeItemIds = excludeIds.toList(), + ) + } + } + val randomDeferred = async(Dispatchers.IO) { fetchItems( @@ -157,7 +188,7 @@ class SuggestionsWorker itemKind = itemKind, sortBy = ItemSortBy.RANDOM, isPlayed = false, - limit = itemsPerRow, + limit = randomLimit, ) } @@ -170,17 +201,15 @@ class SuggestionsWorker sortBy = ItemSortBy.DATE_CREATED, sortOrder = SortOrder.DESCENDING, isPlayed = false, - limit = (itemsPerRow * FRESH_CONTENT_RATIO).toInt().coerceAtLeast(1), + limit = freshLimit, ) } - val seedItems = historyDeferred.await() + val contextual = contextualDeferred.await() val random = randomDeferred.await() val fresh = freshDeferred.await() - val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } - - (fresh + random) + (contextual + fresh + random) .asSequence() .distinctBy { it.id } .filterNot { excludeIds.contains(it.relevantId) } @@ -198,6 +227,7 @@ class SuggestionsWorker limit: Int, sortOrder: SortOrder? = null, genreIds: List? = null, + excludeItemIds: List? = null, extraFields: List = emptyList(), ): List { val request = @@ -209,6 +239,7 @@ class SuggestionsWorker genreIds = genreIds, recursive = true, isPlayed = isPlayed, + excludeItemIds = excludeItemIds, sortBy = listOf(sortBy), sortOrder = sortOrder?.let { listOf(it) }, limit = limit, @@ -225,6 +256,5 @@ class SuggestionsWorker 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/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt index 0df0cf38..088b4678 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.MutableLiveData +import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -23,10 +25,12 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import java.util.UUID +import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class SuggestionsSchedulerServiceTest { @@ -87,4 +91,54 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() verify { mockWorkManager.cancelUniqueWork(SuggestionsWorker.WORK_NAME) } } + + @Test + fun schedules_periodic_work_with_delay_when_cache_empty() = + runTest { + coEvery { mockCache.isEmpty() } returns true + val workRequestSlot = slot() + every { + mockWorkManager.enqueueUniquePeriodicWork( + SuggestionsWorker.WORK_NAME, + any(), + capture(workRequestSlot), + ) + } returns mockk() + + 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()) } + assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay) + } + + @Test + fun schedules_periodic_work_without_delay_when_cache_not_empty() = + runTest { + coEvery { mockCache.isEmpty() } returns false + val workRequestSlot = slot() + every { + mockWorkManager.enqueueUniquePeriodicWork( + SuggestionsWorker.WORK_NAME, + any(), + capture(workRequestSlot), + ) + } returns mockk() + + 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()) } + assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay) + } } 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 index 4e388f21..ebef9aec 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -141,6 +141,85 @@ class SuggestionsWorkerTest { } } + @Test + fun fetches_contextual_suggestions_when_genres_available() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + + val genreId = UUID.randomUUID() + val historyItem = + mockk(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns listOf(mockk { every { id } returns genreId }) + } + val contextualItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val randomItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + + var callCount = 0 + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { + callCount++ + when (callCount) { + 1 -> mockQueryResult(listOf(historyItem)) + 2 -> mockQueryResult(listOf(contextualItem)) + 3 -> mockQueryResult(listOf(randomItem)) + 4 -> mockQueryResult(listOf(freshItem)) + else -> mockQueryResult(emptyList()) + } + } + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } + } + + @Test + fun skips_contextual_suggestions_when_no_genres_available() = + runTest { + val viewId = UUID.randomUUID() + val view = + mockk(relaxed = true) { + every { id } returns viewId + every { this@mockk.collectionType } returns CollectionType.MOVIES + } + every { mockPreferences.data } returns flowOf(mockPrefs()) + coEvery { mockUserViewsApi.getUserViews(userId = testUserId) } returns mockQueryResult(listOf(view)) + mockkObject(GetItemsRequestHandler) + + val historyItem = + mockk(relaxed = true) { + every { id } returns UUID.randomUUID() + every { genreItems } returns emptyList() + } + val randomItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + val freshItem = mockk(relaxed = true) { every { id } returns UUID.randomUUID() } + + var callCount = 0 + coEvery { GetItemsRequestHandler.execute(mockApi, any()) } answers { + callCount++ + when (callCount) { + 1 -> mockQueryResult(listOf(historyItem)) + 2 -> mockQueryResult(listOf(randomItem)) + 3 -> mockQueryResult(listOf(freshItem)) + else -> mockQueryResult(emptyList()) + } + } + + val result = createWorker().doWork() + + assertEquals(ListenableWorker.Result.success(), result) + coVerify { mockCache.put(testUserId, viewId, BaseItemKind.MOVIE, any()) } + } + @Test fun returns_retry_on_network_error() = runTest { From d5602aa88c1b0dd0ae9cda6decd0aa4d0e09a56e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:03:43 -0500 Subject: [PATCH 008/176] Navigation drawer improvements (#842) ## Description This updates the navigation drawer with better behavior and UI. It is also a bit more optimized and is slightly faster. - When open, content is pushed to the right instead of being covered - When open, it uses the same background colors from the dynamic background - The icons no longer shift as the drawer opens & closes - The open drawer is not as wide - Focus highlight on items is less circular, now matching the other menus in the app - The animation is a bit faster too Dev notes: - This PR copies & modifies `tv-material` code, so there's a customized `ModalNavigationDrawer` implementation now - There are fewer `AnimatedVisibility` composables which improves performance - Icons are exactly sized - Content pages generally shouldn't add start/end padding anymore, the padding is now added by the nav drawer (b4f1b111edcd7e7ec05edc1100402b2851721fcd) and is consistent across every page ### Related issues Closes #699 Closes #384 ### Testing Tested on the emulator, nvidia shield, & google sabrina ## Screenshots ![nav_drawer_closed Large](https://github.com/user-attachments/assets/9047eb45-6025-4992-8740-3430f0a8f2a9) ![nav_drawer_open_backdrop_dim Large](https://github.com/user-attachments/assets/880f24a5-8018-469e-805d-18060c641dc5) ## AI or LLM usage None --- .../damontecres/wholphin/ui/cards/ItemRow.kt | 3 +- .../wholphin/ui/detail/CardGrid.kt | 4 +- .../ui/detail/CollectionFolderBoxSet.kt | 6 +- .../ui/detail/CollectionFolderGeneric.kt | 6 +- .../ui/detail/CollectionFolderLiveTv.kt | 6 +- .../ui/detail/CollectionFolderMovie.kt | 6 +- .../ui/detail/CollectionFolderPhotoAlbum.kt | 6 +- .../ui/detail/CollectionFolderPlaylist.kt | 6 +- .../ui/detail/CollectionFolderRecordings.kt | 6 +- .../wholphin/ui/detail/CollectionFolderTv.kt | 5 +- .../wholphin/ui/detail/PersonPage.kt | 4 +- .../wholphin/ui/detail/PlaylistDetails.kt | 3 +- .../ui/detail/episode/EpisodeDetails.kt | 2 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 2 +- .../ui/detail/series/SeriesDetails.kt | 2 +- .../ui/detail/series/SeriesOverviewContent.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 5 +- .../wholphin/ui/nav/ApplicationContent.kt | 16 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 128 +++++----- .../ui/nav/NavigationDrawerAndroid.kt | 229 ++++++++++++++++++ 20 files changed, 334 insertions(+), 117 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavigationDrawerAndroid.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 314a7e1a..7ebf0faf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -58,7 +57,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(start = 8.dp), + modifier = Modifier, ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index f028ed1e..8893246c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -274,7 +274,7 @@ fun CardGrid( horizontalArrangement = Arrangement.spacedBy(spacing), verticalArrangement = Arrangement.spacedBy(spacing), state = gridState, - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(vertical = 16.dp), modifier = Modifier .fillMaxSize() @@ -394,7 +394,7 @@ fun CardGrid( modifier = Modifier .align(Alignment.CenterVertically) - .padding(end = 16.dp), + .padding(start = 16.dp), // Add end padding to push away from edge letterClicked = { letter -> scope.launch(ExceptionHandler()) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt index 91cd66f7..07c24c37 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderBoxSet.kt @@ -1,13 +1,11 @@ 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.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -40,9 +38,7 @@ fun CollectionFolderBoxSet( recursive = recursive, sortOptions = BoxSetSortOptions, initialSortAndDirection = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, 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 800c84c5..e0c15c26 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 @@ -1,13 +1,11 @@ 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 @@ -53,9 +51,7 @@ fun CollectionFolderGeneric( showTitle = showHeader, recursive = recursive, sortOptions = sortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index 6d1fdd02..dc426409 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -134,7 +134,7 @@ fun CollectionFolderLiveTv( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs.map { it.title }, onClick = { selectedTabIndex = it }, @@ -176,9 +176,7 @@ fun CollectionFolderLiveTv( showTitle = false, recursive = false, sortOptions = VideoSortOptions, - modifier = - Modifier - .padding(start = 16.dp), + modifier = Modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index c47aeb23..96ddbe94 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -83,7 +83,7 @@ fun CollectionFolderMovie( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -101,7 +101,6 @@ fun CollectionFolderMovie( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +128,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -162,7 +160,6 @@ fun CollectionFolderMovie( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -180,7 +177,6 @@ fun CollectionFolderMovie( includeItemTypes = listOf(BaseItemKind.MOVIE), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) 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 index c36da909..fd4d46d3 100644 --- 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 @@ -1,13 +1,11 @@ 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 @@ -74,9 +72,7 @@ fun CollectionFolderPhotoAlbum( showTitle = showHeader, recursive = recursive, sortOptions = sortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt index 66ace2d7..e7e19066 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderPlaylist.kt @@ -1,13 +1,11 @@ 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.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderPlaylist( showTitle = showHeader, recursive = recursive, sortOptions = PlaylistSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt index 5362ddb5..a58db1b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderRecordings.kt @@ -1,13 +1,11 @@ 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.model.CollectionFolderFilter import com.github.damontecres.wholphin.preferences.UserPreferences @@ -35,9 +33,7 @@ fun CollectionFolderRecordings( showTitle = showHeader, recursive = recursive, sortOptions = MovieSortOptions, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, positionCallback = { columns, position -> showHeader = position < columns }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index c9fd5381..d72001ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -87,7 +87,7 @@ fun CollectionFolderTv( selectedTabIndex = selectedTabIndex, modifier = Modifier - .padding(start = 32.dp, top = 16.dp, bottom = 16.dp) + .padding(vertical = 16.dp) .focusRequester(firstTabFocusRequester), tabs = tabs, onClick = { selectedTabIndex = it }, @@ -105,7 +105,6 @@ fun CollectionFolderTv( }, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) @@ -129,7 +128,6 @@ fun CollectionFolderTv( defaultViewOptions = ViewOptionsPoster, modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), positionCallback = { columns, position -> @@ -150,7 +148,6 @@ fun CollectionFolderTv( includeItemTypes = listOf(BaseItemKind.SERIES), modifier = Modifier - .padding(start = 16.dp) .fillMaxSize() .focusRequester(focusRequester), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 17535577..5a01d8a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -298,9 +298,7 @@ fun PersonPageContent( LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), userScrollEnabled = !focusedOnHeader, - modifier = - modifier - .padding(start = 16.dp), + modifier = modifier, ) { item { PersonHeader( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt index 7e552525..57c8c5d6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PlaylistDetails.kt @@ -373,7 +373,6 @@ fun PlaylistDetailsContent( horizontalArrangement = Arrangement.spacedBy(24.dp), modifier = Modifier - .padding(horizontal = 8.dp) .fillMaxWidth(), ) { PlaylistDetailsHeader( @@ -386,7 +385,7 @@ fun PlaylistDetailsContent( getPossibleFilterValues = getPossibleFilterValues, modifier = Modifier - .padding(start = 16.dp, top = 80.dp) + .padding(top = 80.dp) .fillMaxWidth(.25f), ) when (loadingState) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index c46fe0cb..ead6cfd4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -303,7 +303,7 @@ fun EpisodeDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index b4c4584d..a5d3a7c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -410,7 +410,7 @@ fun MovieDetailsContent( Box(modifier = modifier) { LazyColumn( verticalArrangement = Arrangement.spacedBy(16.dp), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + contentPadding = PaddingValues(vertical = 8.dp), modifier = Modifier.fillMaxSize(), ) { item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 1db5bb61..cef5412b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -333,7 +333,7 @@ fun SeriesDetailsContent( Column( modifier = Modifier - .padding(horizontal = 24.dp, vertical = 16.dp) + .padding(vertical = 16.dp) .fillMaxSize(), ) { LazyColumn( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index b19e7900..86e2b398 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -127,7 +127,7 @@ fun SeriesOverviewContent( modifier = Modifier .fillMaxSize() - .padding(16.dp) + .padding(vertical = 16.dp) .focusGroup() .nestedScroll(scrollConnection) .verticalScroll(scrollState) @@ -142,9 +142,9 @@ fun SeriesOverviewContent( ) { val paddingValues = if (preferences.appPreferences.interfacePreferences.showClock) { - PaddingValues(start = 16.dp, end = 100.dp) + PaddingValues(start = 0.dp, end = 184.dp) } else { - PaddingValues(start = 16.dp, end = 16.dp) + PaddingValues(start = 0.dp, end = 16.dp) } TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 0e6e172b..ac946d8b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -236,7 +236,7 @@ fun HomePageContent( item = focusedItem, modifier = Modifier - .padding(top = 48.dp, bottom = 32.dp, start = 32.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) LazyColumn( @@ -244,9 +244,6 @@ fun HomePageContent( verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues( - start = 24.dp, - end = 16.dp, - top = 0.dp, bottom = Cards.height2x3, ), modifier = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 2ada8504..ff7643d8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -11,13 +11,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -32,7 +34,9 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.serialization.NavBackStackSerializer import androidx.navigation3.runtime.serialization.NavKeySerializer import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.DrawerValue import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.rememberDrawerState import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.transitionFactory @@ -91,6 +95,7 @@ fun ApplicationContent( navigationManager.backStack = backStack val backdrop by viewModel.backdropService.backdropFlow.collectAsStateWithLifecycle() val backdropStyle = preferences.appPreferences.interfacePreferences.backdropStyle + val drawerState = rememberDrawerState(DrawerValue.Closed) Box( modifier = modifier, ) { @@ -181,9 +186,15 @@ fun ApplicationContent( .align(Alignment.TopEnd) .fillMaxHeight(.7f) .fillMaxWidth(.7f) - .alpha(.95f) + .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen) .drawWithContent { drawContent() + if (drawerState.isOpen) { + drawRect( + brush = SolidColor(Color.Black), + alpha = .75f, + ) + } // Subtle top scrim for system UI readability (clock, tabs) if (enableTopScrim) { drawRect( @@ -245,6 +256,7 @@ fun ApplicationContent( preferences = preferences, user = user, server = server, + drawerState = drawerState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f38d50bd..0b9c8135 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.ui.nav import android.content.Context import androidx.activity.compose.BackHandler -import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.animateIntOffsetAsState +import androidx.compose.animation.core.spring import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState @@ -12,7 +14,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -24,13 +28,13 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties @@ -44,6 +48,7 @@ import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -56,13 +61,10 @@ import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.ModalNavigationDrawer -import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.NavigationDrawerItemDefaults import androidx.tv.material3.NavigationDrawerScope import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text -import androidx.tv.material3.rememberDrawerState import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository @@ -269,6 +271,7 @@ fun NavDrawer( preferences: UserPreferences, user: JellyfinUser, server: JellyfinServer, + drawerState: DrawerState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -277,13 +280,11 @@ fun NavDrawer( key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { - val drawerState = rememberDrawerState(DrawerValue.Closed) - val listState = rememberLazyListState() val scope = rememberCoroutineScope() val context = LocalContext.current + val density = LocalDensity.current val focusRequester = remember { FocusRequester() } - val searchFocusRequester = remember { FocusRequester() } // If the user presses back while on the home page, open the nav drawer, another back press will quit the app BackHandler(enabled = (drawerState.currentValue == DrawerValue.Closed && destination is Destination.Home)) { @@ -302,51 +303,59 @@ fun NavDrawer( viewModel.setShowMore(false) } - val closedDrawerWidth = NavigationDrawerItemDefaults.CollapsedDrawerItemWidth - val drawerBackground by animateColorAsState( - if (drawerState.isOpen) { - MaterialTheme.colorScheme.surface - } else { - Color.Transparent - }, + val closedDrawerWidth = CollapsedDrawerItemWidth + val openDrawerWidth = ExpandedDrawerItemWidth + val offset by animateIntOffsetAsState( + targetValue = + IntOffset( + x = + with(density) { + if (drawerState.isOpen) (openDrawerWidth - closedDrawerWidth).roundToPx() else 0 + }, + y = 0, + ), + animationSpec = + spring( + stiffness = DrawerAnimationStiffness, + visibilityThreshold = IntOffset.VisibilityThreshold, + ), ) - val spacedBy = 4.dp val config = LocalConfiguration.current - val density = LocalDensity.current val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex.. + val isOpen = drawerValue.isOpen + val spacedBy = 4.dp + val listState = rememberLazyListState() + val searchFocusRequester = remember { FocusRequester() } + + suspend fun scrollToSelected() { + val target = selectedIndex + 2 + try { + if (target !in + listState.firstVisibleItemIndex.. Unit, + modifier: Modifier = Modifier, + drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed), + content: @Composable () -> Unit, +) { + Box(modifier = modifier) { + DrawerSheet( + modifier = + Modifier + .align(Alignment.CenterStart), + drawerState = drawerState, + content = drawerContent, + ) + + content() + } +} + +@Composable +private fun DrawerSheet( + modifier: Modifier = Modifier, + drawerState: DrawerState = remember { DrawerState() }, + content: @Composable NavigationDrawerScope.(DrawerValue) -> Unit, +) { + // indicates that the drawer has been set to its initial state and has grabbed focus if + // necessary. Controls whether focus is used to decide the state of the drawer going forward. + var initializationComplete: Boolean by remember { mutableStateOf(false) } + var focusState by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + LaunchedEffect(key1 = drawerState.currentValue) { + if (drawerState.currentValue == DrawerValue.Open && focusState?.hasFocus == false) { + // used to grab focus if the drawer state is set to Open on start. + focusRequester.requestFocus() + } + initializationComplete = true + } + + val internalModifier = + Modifier + .focusRequester(focusRequester) + .animateContentSize( + animationSpec = + spring( + stiffness = DrawerAnimationStiffness, + visibilityThreshold = IntSize.VisibilityThreshold, + ), + ).fillMaxHeight() + // adding passed-in modifier here to ensure animateContentSize is called before other + // size based modifiers. + .then(modifier) + .onFocusChanged { + focusState = it + + if (initializationComplete) { + drawerState.setValue(if (it.hasFocus) DrawerValue.Open else DrawerValue.Closed) + } + }.focusGroup() + + Box(modifier = internalModifier) { + NavigationDrawerScopeImpl(drawerState.currentValue == DrawerValue.Open).apply { + content(drawerState.currentValue) + } + } +} + +internal class NavigationDrawerScopeImpl( + override val hasFocus: Boolean, +) : NavigationDrawerScope + +internal val CollapsedDrawerItemWidth = 64.dp +internal val ExpandedDrawerItemWidth = 224.dp +internal val DrawerIconSize = 24.dp +internal val DrawerIconPadding = (ListItemDefaults.IconSize - DrawerIconSize) / 2 +internal val DrawerAnimationStiffness = Spring.StiffnessMedium + +@Composable +internal fun NavigationDrawerScope.NavigationDrawerItem( + selected: Boolean, + onClick: () -> Unit, + leadingContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = NavigationDrawerItemDefaults.NavigationDrawerItemElevation, + colors: NavigationDrawerItemColors = NavigationDrawerItemDefaults.colors(), + interactionSource: MutableInteractionSource? = null, + content: @Composable () -> Unit, +) { + val animatedWidth by + animateDpAsState( + targetValue = + if (hasFocus) { + ExpandedDrawerItemWidth + } else { + CollapsedDrawerItemWidth + }, + label = "NavigationDrawerItem width open/closed state of the drawer item", + ) + val navDrawerItemHeight = + if (supportingContent == null) { + NavigationDrawerItemDefaults.ContainerHeightOneLine + } else { + NavigationDrawerItemDefaults.ContainerHeightTwoLine + } + + ListItem( + selected = selected, + onClick = onClick, + headlineContent = content, + leadingContent = { + Box( + Modifier + .padding(horizontal = DrawerIconPadding) + .size(DrawerIconSize), + ) { leadingContent() } + }, + trailingContent = trailingContent, + supportingContent = supportingContent, + modifier = + modifier + .layout { measurable, constraints -> + val width = animatedWidth.roundToPx() + val height = navDrawerItemHeight.roundToPx() + val placeable = + measurable.measure( + constraints.copy( + minWidth = width, + maxWidth = width, + minHeight = height, + maxHeight = height, + ), + ) + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + }, + enabled = enabled, + onLongClick = onLongClick, + tonalElevation = tonalElevation, + colors = colors.toToggleableListItemColors(hasFocus), + scale = ListItemDefaults.scale(1f, 1f), + interactionSource = interactionSource, + ) +} + +@Composable +private fun NavigationDrawerItemColors.toToggleableListItemColors(doesNavigationDrawerHaveFocus: Boolean) = + ListItemDefaults.colors( + containerColor = containerColor, + contentColor = if (doesNavigationDrawerHaveFocus) contentColor else inactiveContentColor, + focusedContainerColor = focusedContainerColor, + focusedContentColor = focusedContentColor, + pressedContainerColor = pressedContainerColor, + pressedContentColor = pressedContentColor, + selectedContainerColor = selectedContainerColor, + selectedContentColor = selectedContentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = + if (doesNavigationDrawerHaveFocus) { + disabledContentColor + } else { + disabledInactiveContentColor + }, + focusedSelectedContainerColor = focusedSelectedContainerColor, + focusedSelectedContentColor = focusedSelectedContentColor, + pressedSelectedContainerColor = pressedSelectedContainerColor, + pressedSelectedContentColor = pressedSelectedContentColor, + ) From 72e5e6ae862d150ebac9bbc08969fd83a28b8101 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:38:09 -0500 Subject: [PATCH 009/176] Add option to send relevant media info to server for bug reports (#840) ## Description Adds a item in the More menu to send information about the media item to the server (and app logs). This includes the "media sources" which is info about the files and stream/tracks in the files. It also includes device info, the playback preferences, and the device's transcoding profile. All of this info is useful for debugging playback issues. ### Related issues N/A ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/MediaReportService.kt | 80 +++++++++++++++++++ .../ui/components/CollectionFolderGrid.kt | 3 + .../ui/components/RecommendedContent.kt | 3 + .../ui/components/RecommendedMovie.kt | 10 ++- .../ui/components/RecommendedTvShow.kt | 10 ++- .../wholphin/ui/detail/DebugPage.kt | 10 ++- .../wholphin/ui/detail/DetailUtils.kt | 23 +++++- .../detail/discover/DiscoverMovieDetails.kt | 1 + .../ui/detail/episode/EpisodeDetails.kt | 1 + .../ui/detail/episode/EpisodeViewModel.kt | 2 + .../wholphin/ui/detail/movie/MovieDetails.kt | 1 + .../ui/detail/movie/MovieViewModel.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 1 + .../ui/detail/series/SeriesOverview.kt | 1 + .../ui/detail/series/SeriesViewModel.kt | 2 + .../damontecres/wholphin/ui/main/HomePage.kt | 1 + .../wholphin/ui/main/HomeViewModel.kt | 2 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt new file mode 100644 index 00000000..14a93ba2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaReportService.kt @@ -0,0 +1,80 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.os.Build +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.ExceptionHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.clientLogApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaReportService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val clientInfo: ClientInfo, + private val deviceInfo: DeviceInfo, + private val deviceProfileService: DeviceProfileService, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) { + val json = + Json { + encodeDefaults = false + } + + fun sendReportFor(itemId: UUID) { + ioScope.launchIO(ExceptionHandler(autoToast = true)) { + val item = api.userLibraryApi.getItem(itemId = itemId).content + sendReportFor(item) + } + } + + suspend fun sendReportFor(item: BaseItemDto) { + val sources = + item.mediaSources ?: api.userLibraryApi + .getItem(itemId = item.id) + .content.mediaSources + val sourcesJson = json.encodeToString(sources) + val playbackPrefs = userPreferencesService.getCurrent().appPreferences.playbackPreferences + val serverVersion = serverRepository.currentServer.value?.serverVersion + val deviceProfile = + deviceProfileService.getOrCreateDeviceProfile(playbackPrefs, serverVersion) + val deviceProfileJson = json.encodeToString(deviceProfile) + val body = + """ + Send media info + serverVersion=$serverVersion + clientInfo=$clientInfo + deviceInfo=$deviceInfo + manufacturer=${Build.MANUFACTURER} + model=${Build.MODEL} + apiLevel=${Build.VERSION.SDK_INT} + + playbackPrefs=${playbackPrefs.toString().replace("\n", ", ").replace("\t", " ")} + + deviceProfile=$deviceProfileJson + + mediaSources=$sourcesJson + """.trimIndent() + Timber.w(body) + val response by api.clientLogApi.logFile(body) + showToast(context, "Sent! Filename=${response.fileName}") + } + } 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 89cda77e..6d20211f 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 @@ -58,6 +58,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus @@ -120,6 +121,7 @@ class CollectionFolderViewModel private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, val navigationManager: NavigationManager, + val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @Assisted("recursive") private val recursive: Boolean, @@ -664,6 +666,7 @@ fun CollectionFolderGrid( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(it) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ), ), onDismissRequest = { moreDialog.makeAbsent() }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 869a454d..24b3d502 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -22,6 +22,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem 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.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -46,6 +47,7 @@ abstract class RecommendedViewModel( val context: Context, val navigationManager: NavigationManager, val favoriteWatchManager: FavoriteWatchManager, + val mediaReportService: MediaReportService, private val backdropService: BackdropService, ) : ViewModel() { abstract fun init() @@ -190,6 +192,7 @@ fun RecommendedContent( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(it) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ), ), onDismissRequest = { moreDialog.makeAbsent() }, 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 866efbf3..e44c72f7 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource @@ -58,8 +59,15 @@ class RecommendedMovieViewModel @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, + mediaReportService: MediaReportService, backdropService: BackdropService, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + mediaReportService, + backdropService, + ) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedMovieViewModel 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 2decd21a..4e952033 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService import com.github.damontecres.wholphin.services.SuggestionsResource @@ -62,8 +63,15 @@ class RecommendedTvShowViewModel @Assisted val parentId: UUID, navigationManager: NavigationManager, favoriteWatchManager: FavoriteWatchManager, + mediaReportService: MediaReportService, backdropService: BackdropService, - ) : RecommendedViewModel(context, navigationManager, favoriteWatchManager, backdropService) { + ) : RecommendedViewModel( + context, + navigationManager, + favoriteWatchManager, + mediaReportService, + backdropService, + ) { @AssistedFactory interface Factory { fun create(parentId: UUID): RecommendedTvShowViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index f0ad2c0c..0abcdc07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -49,6 +49,7 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.clientLogApi import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import java.io.BufferedReader import java.io.InputStreamReader import javax.inject.Inject @@ -137,9 +138,13 @@ class DebugViewModel Send App Logs clientInfo=$clientInfo deviceInfo=$deviceInfo + manufacturer=${Build.MANUFACTURER} + model=${Build.MODEL} + apiLevel=${Build.VERSION.SDK_INT} - """.trimIndent() + logcat - val response by api.clientLogApi.logFile(body) + """.trimIndent() + Timber.w(body) + val response by api.clientLogApi.logFile(body + logcat) showToast(context, "Sent! Filename=${response.fileName}") } } @@ -253,6 +258,7 @@ fun DebugPage( "DeviceInfo: ${viewModel.deviceInfo}", "Manufacturer: ${Build.MANUFACTURER}", "Model: ${Build.MODEL}", + "API Level: ${Build.VERSION.SDK_INT}", "Display Modes:", *viewModel.refreshRateService.supportedDisplayModes, ).forEach { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 8a210c27..cebfff70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -25,9 +25,10 @@ import kotlin.time.Duration.Companion.seconds data class MoreDialogActions( val navigateTo: (Destination) -> Unit, - var onClickWatch: (UUID, Boolean) -> Unit, - var onClickFavorite: (UUID, Boolean) -> Unit, - var onClickAddPlaylist: (UUID) -> Unit, + val onClickWatch: (UUID, Boolean) -> Unit, + val onClickFavorite: (UUID, Boolean) -> Unit, + val onClickAddPlaylist: (UUID) -> Unit, + val onSendMediaInfo: (UUID) -> Unit, ) enum class ClearChosenStreams { @@ -205,6 +206,14 @@ fun buildMoreDialogItems( ) }, ) + add( + DialogItem( + text = R.string.send_media_info_log_to_server, + iconStringRes = R.string.fa_file_video, + ) { + actions.onSendMediaInfo.invoke(item.id) + }, + ) } fun buildMoreDialogItemsForHome( @@ -314,6 +323,14 @@ fun buildMoreDialogItemsForHome( }, ) } + add( + DialogItem( + text = R.string.send_media_info_log_to_server, + iconStringRes = R.string.fa_file_video, + ) { + actions.onSendMediaInfo.invoke(itemId) + }, + ) } fun buildMoreDialogItemsForPerson( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 3c22e8a9..7df86615 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -108,6 +108,7 @@ fun DiscoverMovieDetails( onClickWatch = { itemId, watched -> }, onClickFavorite = { itemId, favorite -> }, onClickAddPlaylist = { itemId -> }, + onSendMediaInfo = {}, ) when (val state = loading) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index ead6cfd4..bac9f51a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -97,6 +97,7 @@ fun EpisodeDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ) when (val state = loading) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index ee93050e..7808141e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer @@ -48,6 +49,7 @@ class EpisodeViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index a5d3a7c6..23ed08c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -125,6 +125,7 @@ fun MovieDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ) when (val state = loading) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 8454f0a1..ed46656c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService @@ -64,6 +65,7 @@ class MovieViewModel val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val themeSongPlayer: ThemeSongPlayer, private val favoriteWatchManager: FavoriteWatchManager, private val peopleFavorites: PeopleFavorites, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index cef5412b..55335057 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -230,6 +230,7 @@ fun SeriesDetails( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog.makePresent(itemId) }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ), ) if (showWatchConfirmation) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index d95a5bbb..a8a9d83f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -215,6 +215,7 @@ fun SeriesOverview( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog = it }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ), onChooseVersion = { chooseVersion = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 8ae7a579..3d80aa9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites import com.github.damontecres.wholphin.services.SeerrService @@ -85,6 +86,7 @@ class SeriesViewModel private val trailerService: TrailerService, private val extrasService: ExtrasService, val streamChoiceService: StreamChoiceService, + val mediaReportService: MediaReportService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index ac946d8b..9f67dd2e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -138,6 +138,7 @@ fun HomePage( playlistViewModel.loadPlaylists(MediaType.VIDEO) showPlaylistDialog = itemId }, + onSendMediaInfo = viewModel.mediaReportService::sendReportFor, ), ) dialog = diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 1911e34d..8e7846b8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO @@ -44,6 +45,7 @@ class HomeViewModel val navigationManager: NavigationManager, val serverRepository: ServerRepository, val navDrawerItemRepository: NavDrawerItemRepository, + val mediaReportService: MediaReportService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, private val latestNextUpService: LatestNextUpService, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0021de1..63c7c3d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -484,6 +484,7 @@ Zoom out Slideshow duration Play videos during slideshow + Send media info log to server Disabled From 63b37ef3f42841838cfeb8bf4200207825b2adab Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:21:38 -0500 Subject: [PATCH 010/176] Fix glitchy nav drawer scrolling when opening (#848) ## Description The nav drawer would always glitch a little when it gained focus due it to trying to keep the selected page in view. #842 made this even worst. This PR fixes that issue by hoisting the list scroll state up higher which ensures it is remembered between recompositions. This change also eliminates the need for workarounds to try and scroll it programmatically which is also a tiny performance boost. ### Related issues Follow up to #842 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/nav/ApplicationContent.kt | 3 ++ .../damontecres/wholphin/ui/nav/NavDrawer.kt | 35 ++----------------- 2 files changed, 6 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index ff7643d8..0e3d5a43 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSerializable @@ -231,6 +232,7 @@ fun ApplicationContent( ) } } + val navDrawerListState = rememberLazyListState() NavDisplay( backStack = navigationManager.backStack, onBack = { navigationManager.goBack() }, @@ -257,6 +259,7 @@ fun ApplicationContent( user = user, server = server, drawerState = drawerState, + navDrawerListState = navDrawerListState, onClearBackdrop = viewModel::clearBackdrop, modifier = Modifier.fillMaxSize(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 0b9c8135..6eac3206 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -18,8 +18,8 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Home @@ -41,7 +41,6 @@ import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView @@ -88,12 +87,10 @@ import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus -import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi @@ -272,6 +269,7 @@ fun NavDrawer( user: JellyfinUser, server: JellyfinServer, drawerState: DrawerState, + navDrawerListState: LazyListState, onClearBackdrop: () -> Unit, modifier: Modifier = Modifier, viewModel: NavDrawerViewModel = @@ -320,8 +318,6 @@ fun NavDrawer( visibilityThreshold = IntOffset.VisibilityThreshold, ), ) - val config = LocalConfiguration.current - val heightInPx = remember { with(density) { config.screenHeightDp.dp.roundToPx() } } ModalNavigationDrawer( modifier = modifier, @@ -329,28 +325,8 @@ fun NavDrawer( drawerContent = { drawerValue -> val isOpen = drawerValue.isOpen val spacedBy = 4.dp - val listState = rememberLazyListState() val searchFocusRequester = remember { FocusRequester() } - suspend fun scrollToSelected() { - val target = selectedIndex + 2 - try { - if (target !in - listState.firstVisibleItemIndex.. Date: Mon, 9 Feb 2026 15:36:20 -0500 Subject: [PATCH 011/176] Add settings for max days in next up (#850) ## Description Add a settings to set the maximum amount of time items can appear in Next Up. Options range from 7 days to a year, or no limit (current & default behavior) ### Related issues Closes #662 ### Testing Tested on the emulator and verified URLs, added unit tests ## Screenshots N/A, just a new slider bar setting ## AI or LLM usage None --- .../wholphin/preferences/AppPreference.kt | 40 ++++++ .../preferences/AppPreferencesSerializer.kt | 1 + .../wholphin/services/AppUpgradeHandler.kt | 9 ++ .../wholphin/services/LatestNextUpService.kt | 4 + .../services/tvprovider/TvProviderWorker.kt | 4 +- .../wholphin/ui/main/HomeViewModel.kt | 1 + .../ui/preferences/SliderPreference.kt | 2 +- app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 7 + .../services/SuggestionsWorkerTest.kt | 10 +- .../damontecres/wholphin/test/NextUpTest.kt | 136 ++++++++++++++++++ 11 files changed, 208 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt 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 6015a128..4d7dd76f 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 @@ -186,6 +186,45 @@ sealed interface AppPreference { summarizer = { value -> value?.toString() }, ) + val MaxDaysNextUpOptions = listOf(7, 14, 30, 60, 90, 180, 365) + val MaxDaysNextUp = + AppSliderPreference( + title = R.string.max_days_next_up, + defaultValue = -1, + min = 0, + // Max is "no limit" stored as -1 + max = MaxDaysNextUpOptions.lastIndex + 1L, + interval = 1, + getter = { + MaxDaysNextUpOptions + .indexOf(it.homePagePreferences.maxDaysNextUp) + .takeIf { it in MaxDaysNextUpOptions.indices } + ?.toLong() + ?: MaxDaysNextUpOptions.size.toLong() + }, + setter = { prefs, index -> + prefs.updateHomePagePreferences { + maxDaysNextUp = MaxDaysNextUpOptions.getOrNull(index.toInt()) ?: -1 + } + }, + summarizer = { value -> + if (value != null) { + val v = MaxDaysNextUpOptions.getOrNull(value.toInt()) ?: -1 + if (v == -1) { + WholphinApplication.instance.getString(R.string.no_limit) + } else { + WholphinApplication.instance.resources.getQuantityString( + R.plurals.days, + v, + v.toString(), + ) + } + } else { + null + } + }, + ) + val CombineContinueNext = AppSwitchPreference( title = R.string.combine_continue_next, @@ -956,6 +995,7 @@ val basicPreferences = AppPreference.HomePageItems, AppPreference.CombineContinueNext, AppPreference.RewatchNextUp, + AppPreference.MaxDaysNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, 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 e2182aa3..6adcdca4 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 @@ -82,6 +82,7 @@ class AppPreferencesSerializer maxItemsPerRow = AppPreference.HomePageItems.defaultValue.toInt() enableRewatchingNextUp = AppPreference.RewatchNextUp.defaultValue combineContinueNext = AppPreference.CombineContinueNext.defaultValue + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() }.build() interfacePreferences = InterfacePreferences 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 96130f48..057ec550 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 @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions @@ -237,4 +238,12 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.4.1-14-g0"))) { + appPreferences.updateData { + it.updateHomePagePreferences { + maxDaysNextUp = AppPreference.MaxDaysNextUp.defaultValue.toInt() + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 44ed1a58..6ac016a3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -75,7 +75,10 @@ class LatestNextUpService limit: Int, enableRewatching: Boolean, enableResumable: Boolean, + maxDays: Int, ): List { + val nextUpDateCutoff = + maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } val request = GetNextUpRequest( userId = userId, @@ -86,6 +89,7 @@ class LatestNextUpService enableResumable = enableResumable, enableUserData = true, enableRewatching = enableRewatching, + nextUpDateCutoff = nextUpDateCutoff, ) val nextUp = api.tvShowsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index 134e4903..d8816e71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -81,6 +81,7 @@ class TvProviderWorker userId, prefs.homePagePreferences.enableRewatchingNextUp, prefs.homePagePreferences.combineContinueNext, + prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -145,12 +146,13 @@ class TvProviderWorker userId: UUID, enableRewatching: Boolean, combineContinueNext: Boolean, + maxDaysNextUp: Int, ): List { val resumeItems = latestNextUpService.getResume(userId, 10, true) val seriesIds = resumeItems.mapNotNull { it.data.seriesId } val nextUpItems = latestNextUpService - .getNextUp(userId, 10, enableRewatching, false) + .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } return if (combineContinueNext) { latestNextUpService.buildCombined(resumeItems, nextUpItems) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 8e7846b8..0e571544 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -98,6 +98,7 @@ class HomeViewModel limit, prefs.enableRewatchingNextUp, false, + prefs.maxDaysNextUp, ) val watching = buildList { 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 e5564ee2..ce686708 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 @@ -65,7 +65,7 @@ fun SliderPreference( } Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 41fd78ef..737dbb57 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -82,6 +82,7 @@ message HomePagePreferences{ int32 max_items_per_row = 1; bool enable_rewatching_next_up = 2; bool combine_continue_next = 3; + int32 max_days_next_up = 4; } enum ThemeSongVolume { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 63c7c3d2..2dbad4f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -260,6 +260,11 @@ %d hour %d hours + + %s days + %s day + %s days + %d items %d item @@ -485,6 +490,8 @@ Slideshow duration Play videos during slideshow Send media info log to server + No limit + Max days in Next Up Disabled 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 index ebef9aec..7e5d2af0 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -78,11 +78,6 @@ class SuggestionsWorkerTest { 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 { @@ -231,3 +226,8 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.retry(), result) } } + +fun mockQueryResult(items: List = emptyList()) = + mockk>(relaxed = true) { + every { content } returns mockk { every { this@mockk.items } returns items } + } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt new file mode 100644 index 00000000..0e9bb92e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/NextUpTest.kt @@ -0,0 +1,136 @@ +package com.github.damontecres.wholphin.test + +import android.content.Context +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.updateHomePagePreferences +import com.github.damontecres.wholphin.services.DatePlayedService +import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.mockQueryResult +import io.mockk.CapturingSlot +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.tvShowsApi +import org.jellyfin.sdk.api.operations.TvShowsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.request.GetNextUpRequest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +class NextUpTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private val mockTvShowsApi = mockk() + private val mockApi = mockk(relaxed = true) + private val mockContext = mockk() + private val mockDatePlayedService = mockk() + + private val latestNextUpService = + LatestNextUpService(mockContext, mockApi, mockDatePlayedService) + + @Before + fun setUp() { + every { mockApi.tvShowsApi } returns mockTvShowsApi + } + + @Test + fun `Test max 30 days in next up`() = + runTest { + val maxDays = 30 + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = maxDays, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + val expected = LocalDate.now().minusDays(maxDays.toLong()) + Assert.assertEquals(expected, nextUpSlot.captured.nextUpDateCutoff?.toLocalDate()) + } + + @Test + fun `Test no limit in next up`() = + runTest { + val nextUpSlot = CapturingSlot() + coEvery { mockTvShowsApi.getNextUp(capture(nextUpSlot)) } returns mockQueryResult() + latestNextUpService.getNextUp( + userId = UUID.randomUUID(), + limit = 10, + enableRewatching = true, + enableResumable = true, + maxDays = -1, + ) + Assert.assertEquals(10, nextUpSlot.captured.limit) + Assert.assertNull(nextUpSlot.captured.nextUpDateCutoff) + } + + @Test + fun `Test storing preference`() { + AppPreference.MaxDaysNextUp.setter.invoke(AppPreferences.getDefaultInstance(), 0).let { + Assert.assertEquals(7, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex.toLong(), + ).let { + Assert.assertEquals(365, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke(AppPreferences.getDefaultInstance(), 3) + .let { + Assert.assertEquals(60, it.homePagePreferences.maxDaysNextUp) + } + + AppPreference.MaxDaysNextUp.setter + .invoke( + AppPreferences.getDefaultInstance(), + AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, + ).let { + Assert.assertEquals(-1, it.homePagePreferences.maxDaysNextUp) + } + } + + @Test + fun `Test getting preference`() { + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 7 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(0, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = 60 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(3, result) + } + + AppPreferences + .getDefaultInstance() + .updateHomePagePreferences { maxDaysNextUp = -1 } + .let { + val result = AppPreference.MaxDaysNextUp.getter.invoke(it) + Assert.assertEquals(AppPreference.MaxDaysNextUpOptions.lastIndex + 1L, result) + } + } +} From 28ed1a491bf997b8a93e72d5c908cfd0e8cd4caf Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:28:03 -0500 Subject: [PATCH 012/176] Configure Renovate (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Welcome to [Renovate](https://redirect.github.com/renovatebot/renovate)! This is an onboarding PR to help you understand and configure settings before regular Pull Requests begin. 🚦 To activate Renovate, merge this Pull Request. To disable Renovate, simply close this Pull Request unmerged. --- ### Detected Package Files * `.github/actions/native-build/action.yml` (github-actions) * `.github/actions/setup/action.yml` (github-actions) * `.github/workflows/main.yml` (github-actions) * `.github/workflows/pr.yml` (github-actions) * `.github/workflows/release.yml` (github-actions) * `gradle.properties` (gradle) * `settings.gradle.kts` (gradle) * `build.gradle.kts` (gradle) * `app/build.gradle.kts` (gradle) * `gradle/libs.versions.toml` (gradle) * `gradle/wrapper/gradle-wrapper.properties` (gradle-wrapper) ### Configuration Summary Based on the default config's presets, Renovate will: - Start dependency updates only once this onboarding PR is merged - Hopefully safe environment variables to allow users to configure. - Show all Merge Confidence badges for pull requests. - Enable Renovate Dependency Dashboard creation. - Use semantic commit type `fix` for dependencies and `chore` for all others if semantic commits are in use. - Ignore `node_modules`, `bower_components`, `vendor` and various test/tests (except for nuget) directories. - Group known monorepo packages together. - Use curated list of recommended non-monorepo package groupings. - Show only the Age and Confidence Merge Confidence badges for pull requests. - Apply crowd-sourced package replacement rules. - Apply crowd-sourced workarounds for known problems with packages. - Ensure that every dependency pinned by digest and sourced from GitHub.com contains a link to the commit-to-commit diff - Correctly link to the source code for golang.org/x packages - Link to pkg.go.dev/... for golang.org/x packages' title 🔡 Do you want to change how Renovate upgrades your dependencies? Add your custom config to `renovate.json` in this branch. Renovate will update the Pull Request description the next time it runs. --- ### What to Expect With your current configuration, Renovate will create 11 Pull Requests:
Update Androidx Media3 to v1.9.2 - Schedule: ["at any time"] - Branch name: `renovate/androidx-media3` - Merge into: `main` - Upgrade [androidx.media3:media3-effect](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-ui-compose](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-ui](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer-dash](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer-hls](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-session](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-exoplayer](https://redirect.github.com/androidx/media) to `1.9.2` - Upgrade [androidx.media3:media3-datasource-okhttp](https://redirect.github.com/androidx/media) to `1.9.2`
Update dependency com.google.devtools.ksp to v2.3.5 - Schedule: ["at any time"] - Branch name: `renovate/ksp-monorepo` - Merge into: `main` - Upgrade [com.google.devtools.ksp](https://redirect.github.com/google/ksp) to `2.3.5`
Update Dependencies - Schedule: ["at any time"] - Branch name: `renovate/dependencies` - Merge into: `main` - Upgrade [com.google.dagger:hilt-android-compiler](https://redirect.github.com/google/dagger) to `2.59.1` - Upgrade [com.google.dagger:hilt-android](https://redirect.github.com/google/dagger) to `2.59.1` - Upgrade [com.google.protobuf:protobuf-kotlin-lite](https://redirect.github.com/protocolbuffers/protobuf) to `4.33.5` - Upgrade [com.mikepenz:multiplatform-markdown-renderer-m3](https://redirect.github.com/mikepenz/multiplatform-markdown-renderer) to `0.39.2` - Upgrade [com.mikepenz:multiplatform-markdown-renderer](https://redirect.github.com/mikepenz/multiplatform-markdown-renderer) to `0.39.2` - Upgrade [io.mockk:mockk-android](https://redirect.github.com/mockk/mockk) to `1.14.9` - Upgrade [io.mockk:mockk-agent](https://redirect.github.com/mockk/mockk) to `1.14.9`
Update dependency com.google.dagger.hilt.android to v2.59.1 - Schedule: ["at any time"] - Branch name: `renovate/hilt` - Merge into: `main` - Upgrade [com.google.dagger.hilt.android](https://redirect.github.com/google/dagger) to `2.59.1`
Update dependency python to 3.14 - Schedule: ["at any time"] - Branch name: `renovate/github-actions` - Merge into: `main` - Upgrade [python](https://redirect.github.com/actions/python-versions) to `3.14`
Update Gradle to v8.14.4 - Schedule: ["at any time"] - Branch name: `renovate/gradle-8.x` - Merge into: `main` - Upgrade [gradle](https://redirect.github.com/gradle/gradle) to `8.14.4`
Update Jellyfin SDK to v1.8.6 - Schedule: ["at any time"] - Branch name: `renovate/jellyfin-sdk` - Merge into: `main` - Upgrade [org.jellyfin.sdk:jellyfin-api-okhttp-jvm](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6` - Upgrade [org.jellyfin.sdk:jellyfin-api](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6` - Upgrade [org.jellyfin.sdk:jellyfin-core](https://redirect.github.com/jellyfin/jellyfin-sdk-kotlin) to `1.8.6`
Update Kotlin - Schedule: ["at any time"] - Branch name: `renovate/kotlin` - Merge into: `main` - Upgrade [org.jetbrains.kotlinx:kotlinx-coroutines-test](https://redirect.github.com/Kotlin/kotlinx.coroutines) to `1.10.2` - Upgrade [org.jetbrains.kotlin.plugin.serialization](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.jvm](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.plugin.compose](https://redirect.github.com/JetBrains/kotlin) to `2.3.10` - Upgrade [org.jetbrains.kotlin.android](https://redirect.github.com/JetBrains/kotlin) to `2.3.10`
Update dependency com.android.application to v9 - Schedule: ["at any time"] - Branch name: `renovate/major-agp` - Merge into: `main` - Upgrade [com.android.application](https://android.googlesource.com/platform/tools/base) to `9.0.0`
Update Github Actions (major) - Schedule: ["at any time"] - Branch name: `renovate/major-github-actions` - Merge into: `main` - Upgrade [actions/cache](https://redirect.github.com/actions/cache) to `v5` - Upgrade [actions/checkout](https://redirect.github.com/actions/checkout) to `v6` - Upgrade [actions/setup-python](https://redirect.github.com/actions/setup-python) to `v6` - Upgrade [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) to `v6`
Update Gradle to v9 - Schedule: ["at any time"] - Branch name: `renovate/gradle-9.x` - Merge into: `main` - Upgrade [gradle](https://redirect.github.com/gradle/gradle) to `9.3.1`
🚸 Branch creation will be limited to maximum 2 per hour, so it doesn't swamp any CI resources or overwhelm the project. See docs for `prhourlylimit` for details. --- ❓ Got questions? Check out Renovate's [Docs](https://docs.renovatebot.com/), particularly the Getting Started section. If you need any further assistance then you can also [request help here](https://redirect.github.com/renovatebot/renovate/discussions). --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Damontecres --- renovate.json | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 renovate.json diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000..dc6fbf78 --- /dev/null +++ b/renovate.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ], + "packageRules": [ + { + "groupName": "Dependencies", + "matchDepTypes": ["dependencies"], + "separateMultipleMajor": true + }, + { + "groupName": "Github Actions", + "matchManagers": ["github-actions"] + }, + { + "groupName": "Androidx Media3", + "matchPackageNames": [ + "androidx.media3:**" + ] + }, + { + "groupName": "Jellyfin SDK", + "matchPackageNames": [ + "org.jellyfin.sdk:**" + ] + }, + { + "groupName": "AGP", + "matchPackageNames": [ + "com.android.application:**" + ] + }, + { + "groupName": "Kotlin", + "matchPackageNames": [ + "org.jetbrains.kotlin**" + ] + } + ] +} From b06761eaa58b44760c153c8e1438db32099fa1c5 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:39:40 -0500 Subject: [PATCH 013/176] Rewrite SuggestionsCache & limit parallelism (#851) ## Description A rewrite of the `SuggestionsCache` to simplify it as a read-through cache. The downside of this is that updated suggestions won't be reflected in the UI unless the user reloads the page, but I think that's an okay trade off. Also adds parallelism limits to fetching suggestions to reduce the number of simultaneous API calls. And moved the combining logic to use the `Default` dispatcher to free up an IO one. Also adds an initial delay to both the suggestions & tv provider workers, so a cold start of the app isn't starved. ### Related issues Related to #849 ### Testing Emulator & unit testing ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 2 + .../services/PlaybackLifecycleObserver.kt | 5 - .../wholphin/services/SuggestionService.kt | 48 +++-- .../wholphin/services/SuggestionsCache.kt | 179 ++++-------------- .../services/SuggestionsSchedulerService.kt | 8 +- .../wholphin/services/SuggestionsWorker.kt | 45 +++-- .../tvprovider/TvProviderSchedulerService.kt | 4 +- .../services/SuggestionServiceTest.kt | 8 - .../wholphin/services/SuggestionsCacheTest.kt | 7 +- .../SuggestionsSchedulerServiceTest.kt | 12 +- .../services/SuggestionsWorkerTest.kt | 1 - gradle/libs.versions.toml | 3 + 12 files changed, 99 insertions(+), 223 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5a1dbd68..29c36ac2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -285,6 +285,8 @@ dependencies { ksp(libs.auto.service.ksp) implementation(platform(libs.okhttp.bom)) implementation(libs.okhttp) + implementation(libs.kache) + implementation(libs.kache.file) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) 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 cc9f5779..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 @@ -4,9 +4,6 @@ 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 /** @@ -19,7 +16,6 @@ class PlaybackLifecycleObserver private val navigationManager: NavigationManager, private val playerFactory: PlayerFactory, private val themeSongPlayer: ThemeSongPlayer, - private val suggestionsCache: SuggestionsCache, ) : DefaultLifecycleObserver { private var wasPlaying: Boolean? = null @@ -52,6 +48,5 @@ 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 index a38c713d..db6cef2c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -8,7 +8,6 @@ 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 @@ -50,32 +49,31 @@ class SuggestionService .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 - } + val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty() + 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 + } + } } } 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 index f131b13b..5542ea51 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -4,15 +4,13 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.ui.toServerString +import com.mayakapps.kache.InMemoryKache +import com.mayakapps.kache.ObjectKache import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.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 @@ -38,39 +36,15 @@ class SuggestionsCache constructor( @param:ApplicationContext private val context: Context, ) { + private val cacheDir: File + get() = File(context.cacheDir, "suggestions") 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 + private val memoryCache = + InMemoryKache(maxSize = 8) { + creationScope = CoroutineScope(Dispatchers.IO) } - 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, @@ -78,63 +52,28 @@ class SuggestionsCache 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 { + return memoryCache.getOrPut(key) { + try { + mutex.withLock { + File(cacheDir, "$key.json").inputStream().use { json.decodeFromStream(it) - }?.also { memoryCache[key] = it } - }.onFailure { Timber.w(it, "Failed to read cache: $key") } - .getOrNull() + } + } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null + } } } + @OptIn(ExperimentalSerializationApi::class) suspend fun put( userId: UUID, libraryId: UUID, @@ -142,75 +81,23 @@ class SuggestionsCache ids: List, ) { val key = cacheKey(userId, libraryId, itemKind) - val cached = CachedSuggestions(ids) - val evictedEntry = + val suggestions = CachedSuggestions(ids) + memoryCache.put(key, suggestions) + try { + cacheDir.mkdirs() 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") + File(cacheDir, "$key.json").outputStream().use { + json.encodeToStream(suggestions, it) } - entriesToSave.forEach { (key, value) -> - runCatching { - File(suggestionsDir, "$key.json") - .outputStream() - .use { json.encodeToStream(value, it) } - }.onFailure { Timber.w(it, "Failed to write cache: $key") } } + } catch (ex: Exception) { + Timber.e(ex, "Exception writing to disk cache") } } - - 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 - } } + +fun ObjectKache<*, *>.isEmpty(): Boolean = this.size == 0L + +fun ObjectKache<*, *>.isNotEmpty(): Boolean = !isEmpty() + +suspend fun ObjectKache.containsKey(key: T): Boolean = get(key) != null 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 index 42d59dae..edca582b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -80,13 +80,7 @@ class SuggestionsSchedulerService BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - - if (cache.isEmpty()) { - Timber.i("Suggestions cache empty, scheduling periodic fetch with 30s delay") - periodicWorkRequestBuilder.setInitialDelay(30.seconds.toJavaDuration()) - } else { - Timber.i("Scheduling periodic SuggestionsWorker") - } + .setInitialDelay(60.seconds.toJavaDuration()) workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, 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 index 405947c4..1ee5870b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.exception.ApiClientException import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -31,6 +32,7 @@ import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID +import kotlin.coroutines.CoroutineContext private val BaseItemDto.relevantId: UUID get() = seriesId ?: id @@ -79,6 +81,7 @@ class SuggestionsWorker } val results = supervisorScope { + val context = Dispatchers.IO.limitedParallelism(2, "fetchSuggestions") views .mapNotNull { view -> val itemKind = @@ -90,7 +93,14 @@ class SuggestionsWorker async(Dispatchers.IO) { runCatching { Timber.v("Fetching suggestions for view %s", view.id) - val suggestions = fetchSuggestions(view.id, userId, itemKind, itemsPerRow) + val suggestions = + fetchSuggestions( + context, + view.id, + userId, + itemKind, + itemsPerRow, + ) ensureActive() cache.put( userId, @@ -110,7 +120,6 @@ class SuggestionsWorker } 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() @@ -127,6 +136,7 @@ class SuggestionsWorker } private suspend fun fetchSuggestions( + coroutineContext: CoroutineContext, parentId: UUID, userId: UUID, itemKind: BaseItemKind, @@ -141,7 +151,7 @@ class SuggestionsWorker val freshLimit = (itemsPerRow * 0.3).toInt().coerceAtLeast(1) val historyDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -163,7 +173,7 @@ class SuggestionsWorker val excludeIds = seedItems.mapTo(HashSet()) { it.relevantId } val contextualDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { if (allGenreIds.isEmpty()) { emptyList() } else { @@ -181,7 +191,7 @@ class SuggestionsWorker } val randomDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -193,7 +203,7 @@ class SuggestionsWorker } val freshDeferred = - async(Dispatchers.IO) { + async(coroutineContext) { fetchItems( parentId = parentId, userId = userId, @@ -204,18 +214,19 @@ class SuggestionsWorker limit = freshLimit, ) } + withContext(Dispatchers.Default) { + val contextual = contextualDeferred.await() + val random = randomDeferred.await() + val fresh = freshDeferred.await() - val contextual = contextualDeferred.await() - val random = randomDeferred.await() - val fresh = freshDeferred.await() - - (contextual + fresh + random) - .asSequence() - .distinctBy { it.id } - .filterNot { excludeIds.contains(it.relevantId) } - .toList() - .shuffled() - .take(itemsPerRow) + (contextual + fresh + random) + .asSequence() + .distinctBy { it.id } + .filterNot { excludeIds.contains(it.relevantId) } + .toList() + .shuffled() + .take(itemsPerRow) + } } private suspend fun fetchItems( 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 ab0e04bc..297cbd64 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 @@ -21,6 +21,7 @@ import timber.log.Timber import javax.inject.Inject import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds import kotlin.time.toJavaDuration @ActivityScoped @@ -60,7 +61,8 @@ class TvProviderSchedulerService TvProviderWorker.PARAM_USER_ID to user.user.id.toString(), TvProviderWorker.PARAM_SERVER_ID to user.server.id.toString(), ), - ).build(), + ).setInitialDelay(60.seconds.toJavaDuration()) + .build(), ).await() } } 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 index 2f858ab1..e1663543 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -12,7 +12,6 @@ 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 @@ -86,7 +85,6 @@ class SuggestionServiceTest { 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() @@ -104,7 +102,6 @@ class SuggestionServiceTest { 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))) @@ -125,7 +122,6 @@ class SuggestionServiceTest { 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))) @@ -145,7 +141,6 @@ class SuggestionServiceTest { 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()) @@ -164,7 +159,6 @@ class SuggestionServiceTest { 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 @@ -199,7 +193,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) val cachedId = UUID.randomUUID() coEvery { @@ -237,7 +230,6 @@ class SuggestionServiceTest { val currentUser = MutableLiveData(mockUser(userId)) every { mockServerRepository.currentUser } returns currentUser - every { mockCache.cacheVersion } returns MutableStateFlow(0L) val cachedId = UUID.randomUUID() coEvery { 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 index 709509c6..82e3f2c8 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsCacheTest.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.services import android.content.Context +import com.mayakapps.kache.ObjectKache import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -28,11 +29,11 @@ class SuggestionsCacheTest { return SuggestionsCache(mockContext) } - private fun memoryCacheOf(cache: SuggestionsCache): MutableMap { + private fun memoryCacheOf(cache: SuggestionsCache): ObjectKache { val field = SuggestionsCache::class.java.getDeclaredField("memoryCache") field.isAccessible = true @Suppress("UNCHECKED_CAST") - return field.get(cache) as MutableMap + return field.get(cache) as ObjectKache } @Test @@ -57,7 +58,6 @@ class SuggestionsCacheTest { 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() @@ -192,7 +192,6 @@ class SuggestionsCacheTest { 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() 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 index 088b4678..7da1d63c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -12,7 +12,6 @@ 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.slot @@ -30,7 +29,6 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import java.util.UUID -import java.util.concurrent.TimeUnit @OptIn(ExperimentalCoroutinesApi::class) class SuggestionsSchedulerServiceTest { @@ -65,7 +63,6 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_when_user_present() = runTest { - coEvery { mockCache.isEmpty() } returns false createService() currentLiveData.value = CurrentUser( @@ -79,7 +76,6 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = runTest { - coEvery { mockCache.isEmpty() } returns false createService() currentLiveData.value = CurrentUser( @@ -95,7 +91,6 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { - coEvery { mockCache.isEmpty() } returns true val workRequestSlot = slot() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -114,13 +109,12 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(30000L, workRequestSlot.captured.workSpec.initialDelay) + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } @Test - fun schedules_periodic_work_without_delay_when_cache_not_empty() = + fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { - coEvery { mockCache.isEmpty() } returns false val workRequestSlot = slot() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -139,6 +133,6 @@ class SuggestionsSchedulerServiceTest { advanceUntilIdle() verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } - assertEquals(0L, workRequestSlot.captured.workSpec.initialDelay) + assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } } 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 index 7e5d2af0..7d633d87 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -132,7 +132,6 @@ class SuggestionsWorkerTest { assertEquals(ListenableWorker.Result.success(), result) coVerify { mockCache.put(testUserId, viewId, itemKind, any()) } - coVerify { mockCache.save() } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2a32580..d5298ea6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ desugar_jdk_libs = "2.1.5" hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" +kache = "2.1.1" kotlin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" ksp = "2.3.0" @@ -80,6 +81,8 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } +kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } From 4e45502c1c2580e8f35034b265f404fcba33a7c9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:49:17 -0500 Subject: [PATCH 014/176] Update Androidx Media3 to v1.9.2 (#854) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [androidx.media3:media3-effect](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-effect/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-effect/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-ui-compose](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-ui-compose/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-ui-compose/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-ui](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-ui/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-ui/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer-dash](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer-dash/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer-dash/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer-hls](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer-hls/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer-hls/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-session](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-session/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-session/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-exoplayer](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-exoplayer/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-exoplayer/1.9.1/1.9.2?slim=true) | | [androidx.media3:media3-datasource-okhttp](https://redirect.github.com/androidx/media) | `1.9.1` → `1.9.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/androidx.media3:media3-datasource-okhttp/1.9.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/androidx.media3:media3-datasource-okhttp/1.9.1/1.9.2?slim=true) | --- ### Release Notes
androidx/media (androidx.media3:media3-effect) ### [`v1.9.2`](https://redirect.github.com/androidx/media/blob/HEAD/RELEASENOTES.md#192-2026-02-06) [Compare Source](https://redirect.github.com/androidx/media/compare/1.9.1...1.9.2) This release includes the following changes since [1.9.1 release](#​191-2026-01-26): - ExoPlayer: - Fix bug where `ProgressiveMediaSource` propagates out-of-date timeline info to player and the queued periods unexpectedly get removed ([#​3016](https://redirect.github.com/androidx/media/issues/3016)). - Audio: - Improve the retry logic of `AudioOutput` initialization in `DefaultAudioSink` ([#​2905](https://redirect.github.com/androidx/media/issues/2905)). - Session: - Fix issue where system UI button placement workaround negatively affects other UI surface like Android Auto or manufacturers not needing the workaround ([#​3041](https://redirect.github.com/androidx/media/issues/3041)). - Cast extension: - Fix bug where transferring from Cast to local playback was broken.
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d5298ea6..02ae6466 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,7 +26,7 @@ tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.3" -androidx-media3 = "1.9.1" +androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.0" From 6ec10b9b825ba9d671aa84550d79e3a318fca98a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 17:51:49 -0500 Subject: [PATCH 015/176] Update dependency com.google.devtools.ksp to v2.3.5 (#855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.devtools.ksp](https://goo.gle/ksp) ([source](https://redirect.github.com/google/ksp)) | `2.3.0` → `2.3.5` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.5?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.0/2.3.5?slim=true) | --- ### Release Notes
google/ksp (com.google.devtools.ksp) ### [`v2.3.5`](https://redirect.github.com/google/ksp/releases/tag/2.3.5) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.4...2.3.5) #### What's Changed - KSPCoreEnvironment.instance\_prop leaks memory when used programmatically [#​2742](https://redirect.github.com/google/ksp/issues/2742) - Missing first annotation argument when toByte is used [#​2672](https://redirect.github.com/google/ksp/issues/2672) - Fix circular dependency between KSP and KAPT in AGP 9.0 [#​2743](https://redirect.github.com/google/ksp/issues/2743) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.4`](https://redirect.github.com/google/ksp/releases/tag/2.3.4) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.3...2.3.4) #### What's Changed - KSP ignores sources in Kotlin directory [#​2730](https://redirect.github.com/google/ksp/issues/2730) - Avoid recording Java symbol lookups in non-incremental builds [#​2728](https://redirect.github.com/google/ksp/issues/2728) - Clean up ThreadLocals when processing is done [#​2709](https://redirect.github.com/google/ksp/issues/2709) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.3`](https://redirect.github.com/google/ksp/releases/tag/2.3.3) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.2...2.3.3) #### What's Changed - Migrate away from a deprecated compilerOptions KGP API [#​2703](https://redirect.github.com/google/ksp/issues/2703) #### Contributors - Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.2`](https://redirect.github.com/google/ksp/releases/tag/2.3.2) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.1...2.3.2) #### What's Changed **Note:** This release is a hotfix for a regression introduced in 2.3.1 - Fixed an issue where KSP incorrectly processed specific nullable annotations from Java interfaces, leading to incorrect nullability in the generated Kotlin code [#​2696](https://redirect.github.com/google/ksp/issues/2696) - Fixed a regression introduced in [#​2656](https://redirect.github.com/google/ksp/issues/2656) that caused runtime failures for projects using AGP 8.8.0 and older due to an incompatible, version-specific type check. [#​2694](https://redirect.github.com/google/ksp/issues/2694) #### Contributors Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**: ### [`v2.3.1`](https://redirect.github.com/google/ksp/releases/tag/2.3.1) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.0...2.3.1) #### What's Changed - Added support for AGP 9.0 and built-in Kotlin [#​2674](https://redirect.github.com/google/ksp/issues/2674) - Fixed a bug in getJvmCheckedException that incorrectly handled nested classes. [#​2584](https://redirect.github.com/google/ksp/issues/2584) - Removed incorrect caching for KSValueArgumentLiteImpl that wrongly merged arguments with different parents, origins, or locations [#​2677](https://redirect.github.com/google/ksp/issues/2677) #### Contributors Thanks to everyone who reported bugs and participated in discussions! **Full Changelog**:
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 02ae6466..32a80878 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.0" kotlinxCoroutinesCore = "1.10.2" -ksp = "2.3.0" +ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.01.01" From 6954d821ed50e74d8f9fd2aeb6b416275d0c888d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:27:10 -0500 Subject: [PATCH 016/176] Fix two UI issues (#856) ## Description Fixes UI issues: - Showing unplayed corner text on multi-part movies - Fix duplicate or missing rating info on discover movie & series pages ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 18 ++++++++----- .../damontecres/wholphin/ui/Formatting.kt | 26 +++++++++---------- .../discover/DiscoverMovieDetailsHeader.kt | 3 ++- .../detail/discover/DiscoverSeriesDetails.kt | 2 +- .../damontecres/wholphin/ui/main/HomePage.kt | 2 +- 5 files changed, 28 insertions(+), 23 deletions(-) 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 869a0398..2d8c7baa 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 @@ -91,12 +91,16 @@ data class BaseItem( episodeCornerText = data.indexNumber?.let { "E$it" } ?: data.premiereDate?.let(::formatDateTime), - episdodeUnplayedCornerText = - data.indexNumber?.let { "E$it" } - ?: data.userData - ?.unplayedItemCount - ?.takeIf { it > 0 } - ?.let { abbreviateNumber(it) }, + episodeUnplayedCornerText = + if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) { + data.indexNumber?.let { "E$it" } + ?: data.userData + ?.unplayedItemCount + ?.takeIf { it > 0 } + ?.let { abbreviateNumber(it) } + } else { + null + }, quickDetails = buildAnnotatedString { val details = @@ -207,6 +211,6 @@ val BaseItemDto.aspectRatioFloat: Float? get() = width?.let { w -> height?.let { @Immutable data class BaseItemUi( val episodeCornerText: String?, - val episdodeUnplayedCornerText: String?, + val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index e4f107b4..1842620a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -167,19 +167,19 @@ fun listToDotString( strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() - communityRating?.let { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - criticRating?.let { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } + } + communityRating?.let { + dot() + append(String.format(Locale.getDefault(), "%.1f", it)) + appendInlineContent(id = "star") + } + criticRating?.let { + dot() + append("${it.toInt()}%") + if (it >= 60f) { + appendInlineContent(id = "fresh") + } else { + appendInlineContent(id = "rotten") } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 79ccb8dd..d8647132 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -65,7 +65,7 @@ fun DiscoverMovieDetailsHeader( ) { val padding = 4.dp val details = - remember(movie) { + remember(movie, rating) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -89,6 +89,7 @@ fun DiscoverMovieDetailsHeader( ?.releaseDates ?.firstOrNull() ?.certification + ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { listToDotString( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index dcaed1c5..64748bd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -463,7 +463,7 @@ fun DiscoverSeriesDetailsHeader( ) { val padding = 4.dp val details = - remember(series) { + remember(series, rating) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 9f67dd2e..cfb13ff7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -326,7 +326,7 @@ fun HomePageContent( name = item?.data?.seriesName ?: item?.name, item = item, aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episdodeUnplayedCornerText, + cornerText = item?.ui?.episodeUnplayedCornerText, played = item?.data?.userData?.played ?: false, favorite = item?.favorite ?: false, playPercent = From 6d5726a5822c0b57f737efe50db4cf21fe2d6d6e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 18:29:27 -0500 Subject: [PATCH 017/176] Update Github Actions (major) (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/cache](https://redirect.github.com/actions/cache) | action | major | `v4` → `v5` | | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` → `v6` | | [actions/setup-python](https://redirect.github.com/actions/setup-python) | action | major | `v5` → `v6` | | [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) | action | major | `v5` → `v6` | --- ### Release Notes
actions/cache (actions/cache) ### [`v5`](https://redirect.github.com/actions/cache/compare/v4...v5) [Compare Source](https://redirect.github.com/actions/cache/compare/v4...v5)
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
actions/setup-python (actions/setup-python) ### [`v6`](https://redirect.github.com/actions/setup-python/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/setup-python/compare/v5...v6)
actions/upload-artifact (actions/upload-artifact) ### [`v6`](https://redirect.github.com/actions/upload-artifact/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/upload-artifact/compare/v5...v6)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://redirect.github.com/renovatebot/renovate/discussions) if that's undesired. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/actions/native-build/action.yml | 8 ++++---- .github/actions/setup/action.yml | 2 +- .github/workflows/main.yml | 2 +- .github/workflows/pr.yml | 8 ++++---- .github/workflows/release.yml | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/actions/native-build/action.yml b/.github/actions/native-build/action.yml index 309c6b59..770e552a 100644 --- a/.github/actions/native-build/action.yml +++ b/.github/actions/native-build/action.yml @@ -10,7 +10,7 @@ runs: - name: Load ffmpeg module cache id: cache_ffmpeg_module if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | app/libs @@ -18,7 +18,7 @@ runs: - name: Load libmpv module cache id: cache_libmpv_module if: ${{ inputs.cache }} - uses: actions/cache/restore@v4 + uses: actions/cache/restore@v5 with: path: | app/src/main/libs @@ -43,7 +43,7 @@ runs: ./build_ffmpeg_decoder.sh "${{ env.ANDROID_SDK_ROOT }}/ndk/${{ env.NDK_VERSION }}" - name: Save ffmpeg module cache id: cache_ffmpeg_module_save - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | app/libs @@ -75,7 +75,7 @@ runs: #ln -s libs jniLibs - name: Save libmpv module cache id: cache_libmpv_module_save - uses: actions/cache/save@v4 + uses: actions/cache/save@v5 with: path: | app/src/main/libs diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 1153cc97..5450bb87 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -6,7 +6,7 @@ runs: steps: # Setup the SDKs - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: '3.12' - name: Setup JDK diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 539f0572..9583525d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,7 +25,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 56513c4b..6a18c5c3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup Python @@ -31,7 +31,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup @@ -49,13 +49,13 @@ jobs: - name: Tar build dirs run: | tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 id: upload-build-dirs with: name: "${{ env.BUILD_DIRS_ARTIFACT }}" path: build.tgz if-no-files-found: error - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v6 with: name: APKs path: "${{ steps.buildapp.outputs.apks }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7c5c45f9..b63017b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: contents: write steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 # Need the tags to build - name: Setup From c279d9c9f72895bbeededa8b82cc006b751f2231 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:46:47 -0500 Subject: [PATCH 018/176] Update Gradle to v8.14.4 (#862) --- gradle/wrapper/gradle-wrapper.jar | Bin 59203 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 5 +- gradlew | 298 ++++++++++++++--------- gradlew.bat | 41 ++-- 4 files changed, 208 insertions(+), 136 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c023ec8b20f512888fe07c5bd3ff77bb8f..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch literal 43764 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

K7UpUK$>l@ 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 E2O(~oYXATM diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fbff848d..aaaabb3c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Sat Sep 20 16:26:22 EDT 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0c..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,81 +15,115 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index ac1b06f9..5eed7ee8 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +27,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal From 6b068079fefea00bb1ac8f74332d552c395b982a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 20:47:07 -0500 Subject: [PATCH 019/176] Update dependency python to 3.14 (#861) --- .github/actions/setup/action.yml | 2 +- .github/workflows/pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 5450bb87..774dae38 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -8,7 +8,7 @@ runs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.14' - name: Setup JDK uses: actions/setup-java@v5 with: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6a18c5c3..db31b32e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Python uses: actions/setup-python@v6 with: - python-version: '3.12' + python-version: '3.14' - name: Run pre-commit uses: pre-commit/action@v3.0.1 From df2f03cf83d392b27ab5c37ce0204d765cb00fac Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:26:11 -0500 Subject: [PATCH 020/176] Update dependencies (#866) ## Description Update dependencies ### Related issues Just updating some of the dependencies in #858 that don't require AGP 9 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- gradle/libs.versions.toml | 8 ++++---- renovate.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32a80878..92c7835c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,9 +15,9 @@ ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.01.01" -mockk = "1.14.7" +mockk = "1.14.9" robolectric = "4.16.1" -multiplatformMarkdownRenderer = "0.39.1" +multiplatformMarkdownRenderer = "0.39.2" okhttpBom = "5.3.2" programguide = "1.6.0" slf4j2Timber = "1.2" @@ -35,14 +35,14 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.33.4" +protobuf-javalite = "4.33.5" hilt = "2.58" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" -kotlinxCoroutinesTest = "1.7.3" +kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.19.0" diff --git a/renovate.json b/renovate.json index dc6fbf78..03e6a404 100644 --- a/renovate.json +++ b/renovate.json @@ -28,7 +28,7 @@ { "groupName": "AGP", "matchPackageNames": [ - "com.android.application:**" + "com.android.application**" ] }, { From f7efbf9f872f28891deb329e5a205b878f16ad3a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 10 Feb 2026 14:59:27 -0500 Subject: [PATCH 021/176] Don't show directed by line if director is unknown --- .../damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 4d5f72d7..45e3ee54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -113,6 +113,7 @@ fun MovieDetailsHeader( movie.data.people ?.filter { it.type == PersonKind.DIRECTOR && it.name.isNotNullOrBlank() } ?.joinToString(", ") { it.name!! } + ?.takeIf { it.isNotNullOrBlank() } } directorName From 404a883eb49d2d20ad251d67773b16ee3a15b529 Mon Sep 17 00:00:00 2001 From: haldq Date: Sat, 31 Jan 2026 20:57:51 +0000 Subject: [PATCH 022/176] Translated using Weblate (German) Currently translated at 94.1% (341 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 03038fc8..614bbdcf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -371,7 +371,7 @@ Im Trend Starten der Spracherkennung fehlgeschlagen Zurück wählen um abzubrechen - MPV ist jetzt der Standard-Player, außer für HDR.\nDas kannst du in den Einstellungen ändern. + MPV ist jetzt der Standard-Player, außer für HDR.\nDu kannst das in den Einstellungen ändern. Sprachsuche Sprechen, um zu suchen Ignoriert geräte kompatibilität @@ -384,4 +384,23 @@ Hintergrundstil Bildwiederholraten-Anpassung Keine Spracheingabe erkannt + Spracherkennungsfehler + Aufnahme-Fehler + Keine Spracherkennung + Spracherkennung beschäftigt + Spracherkennung ist abgelaufen + Videobereich + Video-Bereichstyp + Farbkorrektur-Software + Spur-Auswahl löschen + Seerr-Server entfernen + AV1 Software-Dekodierung + anamorph + Farbraum + Farbübertragung + Primärfarben + Pixelformat + Referenzbilder + Randgröße + Auflösungswechsel From 8605fcafa8cdd475632a4ecc7970ab1f67b0649d Mon Sep 17 00:00:00 2001 From: damontecres Date: Sat, 31 Jan 2026 02:38:33 +0000 Subject: [PATCH 023/176] Translated using Weblate (Spanish) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 148 ++++++++++++------------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 35d7e885..cd2de467 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -24,11 +24,11 @@ Favoritos Tamaño Géneros - Página de inicio + Inicio Inmediato Intro #ABCDEFGHIJKLMNOPQRSTUVWXYZ - Catálogo + Biblioteca Información de licencia Tasa de bits máxima Más @@ -46,7 +46,7 @@ Agregado recientemente en %1$s Agregado recientemente Recomendado - Reiniciarlo + Reiniciar Reanudar Guardar @@ -65,9 +65,9 @@ Cambiar Tráiler - Tráilers - - + TráilerTráilers + + Series Interfaz @@ -85,9 +85,9 @@ Duración Extras - Otros - - + OtroOtros + + Configuración avanzada Interfaz avanzada @@ -109,7 +109,7 @@ Grabaciones activas Cancelar grabación Cancelar grabación de la serie - Borrar la caché de imágenes + Borrar caché de imágenes Valoración de la comunidad ¡Se ha producido un error! Pulsa el botón para enviar los registros a tu servidor. Predeterminado @@ -119,41 +119,41 @@ Servidores detectados Descargando… - Introduce la IP o la URL del servidor - Error al cargar la colección %1$s - Pista forzada + Introduce la IP o URL del servidor, incluido el puerto + Error cargando la colección %1$s + Forzado Ir a la serie Ir a - Ocultar los controles de reproducción + Ocultar controles de reproducción Ocultar información de depuración Ocultar - Televisión en directo + TV en vivo Cargando… - ¿Marcar toda la serie como vista? - ¿Marcar toda la serie como no reproducida? + ¿Marcar la serie completa como vista? + ¿Marcar la serie completa como no vista? Marcar como no visto Marcar como visto Más como esto Películas - A continuación + Siguiente Sin datos - No hay grabaciones programadas - No hay ninguna actualización disponible + Sin grabaciones programadas + No hay actualizaciones disponibles Ruta - Recuento de reproducciones + Reproducciones Reproducir desde aquí Mostrar información de depuración de reproducción Velocidad de reproducción Configuración del perfil de usuario Grabado recientemente Lanzado recientemente - Mostrar \"A continuación\" + Mostrar “Siguiente” Fecha de añadido Ordenar por fecha de reproducción - Ordenar por fecha de lanzamiento - La descarga está tardando demasiado; puede que necesites reiniciar la reproducción - Mejor valoradas sin ver - Programación del DVR + Fecha de lanzamiento + La descarga está tardando demasiado, es posible que debas reiniciar la reproducción + Mejor calificados no vistos + Programación DVR Guía Temporada Temporadas @@ -162,16 +162,16 @@ Escala de vídeo Ver en directo Orden de emisión - Poner la fuente en cursiva + Cursiva - Detrás de las cámaras - - + Detrás de cámarasDetrás de cámaras + + - Canciones principales - - + Canción principalCanciones principales + + Vídeos temáticos @@ -179,29 +179,29 @@ - Clip - Clips + ClipClips + - Escenas eliminadas - - + Escena eliminadaEscenas eliminadas + + - Entrevistas - - + EntrevistaEntrevistas + + - Escenas - - + EscenaEscenas + + - Fragmentos - - + MuestraMuestras + + Mini-documentales @@ -209,14 +209,14 @@ - Cortos - - + CortoCortos + + - %s descarga - %s descargas - + %s descarga%s descargas + + %d hora @@ -233,32 +233,32 @@ %d segundos - El dispositivo es compatible con AC3/Dolby Digital - Comprobar actualizaciones automáticamente - Retraso antes de reproducir lo siguiente - Reproducir automáticamente lo siguiente - Comprobar si hay actualizaciones - Se aplica solo a series de televisión - + El dispositivo soporta AC3/Dolby Digital + Buscar actualizaciones automáticamente + Retraso antes de reproducir el siguiente + Reproducción automática del siguiente + Buscar actualizaciones + Aplica solo a series + Reproducción directa de subtítulos ASS Reproducción directa de subtítulos PGS Convertir siempre a estéreo - Usar módulo de decodificación FFmpeg - Escala predeterminada del contenido + Usar módulo decodificador FFmpeg + Escala de contenido predeterminada Instalar actualización Máximo de elementos por fila en inicio Elige los elementos predeterminados que se mostrarán; los demás permanecerán ocultos Personalizar los elementos del panel de navegación Clic para cambiar de página - Cambiar las páginas del menú lateral al recibir el foco + Cambiar páginas del menú al enfocar Protección contra inactividad Anulaciones de reproducción - Recordar las pestañas seleccionadas - Permitir volver a ver en “A continuación” + Recordar pestañas seleccionadas + Permitir volver a ver en “Siguiente” Pasos de la barra de búsqueda Útil para depuración - Enviar los registros de la app al servidor actual - Intentará enviarlos al último servidor conectado + Enviar registros de la app al servidor actual + Intentará enviarse al último servidor conectado Enviar informes de fallos Retroceder al reanudar la reproducción Retroceder @@ -266,22 +266,22 @@ Avanzar Comportamiento al saltar la intro Comportamiento al saltar el outro - Comportamiento al saltar los avances - Comportamiento al saltar el resumen + Comportamiento al saltar avances + Comportamiento al saltar resumen Actualización disponible - URL para comprobar actualizaciones de la aplicación + URL usada para comprobar actualizaciones URL de actualización Tamaño de fuente - Color de la fuente - Opacidad de la fuente + Color de fuente + Opacidad de fuente Estilo del borde Color del borde Opacidad del fondo Estilo del fondo - Estilo de los subtítulos + Estilo de subtítulos Color del fondo Restablecer - Fuente en negrita + Negrita Motor de reproducción MPV: Usar decodificación por hardware Desactívalo si experimentas fallos From 4dd6e3a6c01e8a537c76edb26c8592f0126a0530 Mon Sep 17 00:00:00 2001 From: danyrd92 Date: Sat, 31 Jan 2026 02:41:49 +0000 Subject: [PATCH 024/176] Translated using Weblate (Spanish) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 29 +++++++++++++------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cd2de467..2b24e4c2 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -53,7 +53,7 @@ Buscar Buscando… Seleccionar servidor - Seleccionar usuario + Seleccionar Usuario Nombre Aleatorio Estudios @@ -72,8 +72,8 @@ Series Interfaz Versión - Video - Videos + Vídeo + Vídeos %1$d años Reproducir con transcodificación Mostrar reloj @@ -97,14 +97,14 @@ Configuración Calificación de la crítica Resumen - Grabar programa + Grabar Programa Grabar serie Quitar de favoritos Mostrar información de depuración Mostrar Aleatorio Saltar - Ordenar por fecha de añadido del episodio + Ordenar por fecha de inclusión del episodio Acerca de Grabaciones activas Cancelar grabación @@ -148,8 +148,8 @@ Grabado recientemente Lanzado recientemente Mostrar “Siguiente” - Fecha de añadido - Ordenar por fecha de reproducción + Fecha de inclusión + Fecha de reproducción Fecha de lanzamiento La descarga está tardando demasiado, es posible que debas reiniciar la reproducción Mejor calificados no vistos @@ -214,8 +214,8 @@ - %s descarga%s descargas - + %s descarga + %s descargas @@ -246,9 +246,9 @@ Usar módulo decodificador FFmpeg Escala de contenido predeterminada Instalar actualización - Máximo de elementos por fila en inicio + Elementos máximos en filas de inicio Elige los elementos predeterminados que se mostrarán; los demás permanecerán ocultos - Personalizar los elementos del panel de navegación + Personalizar elementos del panel de navegación Clic para cambiar de página Cambiar páginas del menú al enfocar Protección contra inactividad @@ -303,7 +303,7 @@ Fuente Fondo Clasificación parental - Termina en %1$s + Termina a las %1$s Introduzca la dirección del servidor No se encontraron servidores Información multimedia @@ -385,13 +385,13 @@ Error de reconocimiento de voz Se requiere permiso para el micrófono Error de red - Tiempo de espera agotado de red + Tiempo de espera de red agotado No se reconoce el habla Reconocimiento de voz ocupado Error de servidor No se detecta voz Error desconocido - No se pudo iniciar el reconocimiento de voz + Error al iniciar el reconocimiento de voz Se agotó el tiempo de espera para el reconocimiento de voz Reintentar Cambio de resolución @@ -421,4 +421,5 @@ Próximos programas de televisión Solicitar en 4K Decodificación por software AV1 + MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes. From 1c9afd0408529f184628bbfae8d88ab0b711865c Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 31 Jan 2026 16:06:19 +0000 Subject: [PATCH 025/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fd54746c..06a641bc 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -389,4 +389,5 @@ 語音辨識逾時 重試 AV1 軟體解碼 + 除了 HDR 影片外,MPV 已成為預設播放器。 \n您可以在設定中變更此設定。 From 4155556c0599f25590990f790f6041e5795161a4 Mon Sep 17 00:00:00 2001 From: opakholis Date: Sun, 1 Feb 2026 04:13:44 +0000 Subject: [PATCH 026/176] Translated using Weblate (Indonesian) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index c1bf5242..6f8f49c7 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -389,4 +389,5 @@ Minta Tertunda Dekode perangkat lunak AV1 + MPV sekarang adalah pemutar bawaan kecuali konten HDR.\nPerubahan dapat dilakukan di menu pengaturan. From 1a4daf80785f8db8308b4a16b2dfb2d68f374929 Mon Sep 17 00:00:00 2001 From: idezentas Date: Mon, 2 Feb 2026 20:21:56 +0000 Subject: [PATCH 027/176] Added translation using Weblate (Turkish) --- app/src/main/res/values-tr/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-tr/strings.xml diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 4b8ee5f5de5280a6ac4be04927e082c60b43da17 Mon Sep 17 00:00:00 2001 From: subatomic9345 Date: Tue, 3 Feb 2026 10:18:50 +0000 Subject: [PATCH 028/176] Translated using Weblate (German) Currently translated at 94.4% (342 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 614bbdcf..5cec8ebf 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -403,4 +403,5 @@ Referenzbilder Randgröße Auflösungswechsel + Gaststars From 47f12b87350aa3d45a2e46cd1f5cb17e01fde8c3 Mon Sep 17 00:00:00 2001 From: idezentas Date: Tue, 3 Feb 2026 14:37:29 +0000 Subject: [PATCH 029/176] Translated using Weblate (Turkish) Currently translated at 100.0% (362 of 362 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 417 ++++++++++++++++++++++++- 1 file changed, 416 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 55344e51..7ba907c2 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,3 +1,418 @@ - \ No newline at end of file + Hakkında + Etkin kayıtlar + Favori + Sunucu Ekle + Kullanıcı Ekle + Ses + Doğum yeri + Bit hızı + Doğum Tarihi + Kaydı iptal et + Dizi kaydını iptal et + İptal + Bölümler + Seç: %1$s + Resim önbelleğini temizle + Koleksiyon + Koleksiyonlar + Reklam + Topluluk puanı + Bir hata oluştu! Günlükleri sunucunuza göndermek için düğmeye basın. + Onayla + İzlemeye devam et + Eleştirmen puanı + %.1f saniye + Varsayılan + Sil + Ölüm Tarihi + Yönetmen %1$s + Yönetmen + Devre dışı + Keşfedilen sunucular + + İndiriliyor… + Etkin + Bitiş zamanı %1$s + Sunucu IP adresini veya URL\'sini girin + Sunucu adresini girin + Bölümler + %1$s koleksiyonu yüklenirken bir hata oluştu + Harici + Favoriler + Boyut + Zorunlu + Türler + Dizilere git + Git + Oynatma kontrollerini gizle + Hata ayıklama bilgilerini gizle + Gizle + Ana Ekran + Hemen + İntro + #ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ + Kütüphane + Lisans bilgileri + Canlı TV + Yükleniyor… + Tüm diziyi izlendi olarak işaretle? + Tüm diziyi izlenmedi olarak işaretle? + İzlenmedi olarak işaretle + İzlendi olarak işaretle + Maksimum bit hızı + Benzerleri + Diğer + + Filmler + + + Ad + Sıradaki bölümler + Veri yok + Sonuç bulunamadı + Sunucu bulunamadı + Planlanmış kayıt yok + Güncelleme mevcut değil + Hiçbiri + Sadece zorunlu altyazılar + Kapanış jeneriği + Yol + + Kişiler + + + Oynatma Sayısı + Buradan oynat + Oynat + Oynatma hata ayıklama bilgilerini göster + Oynatma hızı + Oynatma + Oynatma Listesi + Oynatma Listeleri + Önizleme + Kullanıcı Profili Ayarları + Kuyruk + Özet + %1$s kütüphanesine son eklenenler + Son eklenen + Son Kaydedilenler + Son Çıkanlar + Önerilenler + Programı Kaydet + Diziyi Kaydet + Favorilerden kaldır + Yeniden Başlat + Sürdür + Kaydet + + Ara + Aranıyor… + Sesli arama + Başlatılıyor + Aramak için konuşun + İşleniyor + İptal etmek için geri tuşuna basın + Ses kayıt hatası + Ses tanıma hatası + Mikrofon izni gerekiyor + Ağ hatası + Ağ zaman aşımı + Ses tanınmadı + Ses tanıma meşgul + Sunucu hatası + Ses algılanamadı + Bilinmeyen hata + Ses tanıma başlatılamadı + Ses tanıma zaman aşımına uğradı + Yeniden deneyin + Sunucu Seç + Kullanıcı Seç + Hata ayıklama bilgisini göster + Sıradakini göster + Göster + Karıştır + Atla + Eklenme tarihi + Bölüm Eklenme Tarihi + Oynatma Tarihi + Yayınlanma Tarihi + Ad + Rastgele + Yapım Stüdyoları + Gönder + İndirme işlemi uzun sürüyor, oynatmayı yeniden başlatmanız gerekebilir + Altyazı + Altyazılar + Öneriler + Sunucu değiştir + Değiştir + İzlenmemiş En İyiler + Fragman + + Fragmanlar + + + Kayıt Takvimi + Rehber + Sezon + Sezonlar + + Diziler + + + Arayüz + Bilinmiyor + Güncellemeler + Sürüm + Video Ölçeği + Video + Videolar + Canlı izle + %1$d yaşında + Kod dönüştürerek oynat + Medya Bilgisi + Saati göster + Yayınlanma Sırası + Yeni oynatma listesi oluştur + Oynatma listesine ekle + Tek tıkla duraklat + Durdur/Oynat için orta tuşa basın + Metni italik yap + Font + Arka plan + Başarılı + Yaş Derecelendirmesi + Süre + Ekstralar + + Diğer Ekstralar + + + + Sahne Arkası + + + + Tema şarkıları + + + + Tema Videoları + + + + Klipler + + + + Silinmiş Sahneler + + + + Röportajlar + + + + Sahneler + + + + Örnekler + + + + Tanıtımlar + + + + Kısa Videolar + + + + %s indirme + %s indirilenler + + + %d saat + %d saat + + + %d öğe + %d öğe + + + %d saniye + %d saniye + + AC3/Dolby Digital desteği + Gelişmiş Ayarlar + Gelişmiş Arayüz + Uygulama teması + Güncellemeleri otomatik denetle + Sıradakini oynatma gecikmesi + Sıradakini otomatik oynat + Güncellemeleri kontrol et + Yalnızca diziler için geçerlidir + + ASS altyazıları doğrudan oynat + PGS altyazıları doğrudan oynat + Her zaman Stereo\'ya dönüştür + FFmpeg kod çözücü kullan + Varsayılan içerik ölçeği + Güncellemeyi yükle + Yüklü sürüm + Ana sayfa satırlarında maksimum öge sayısı + Gösterilecek varsayılan ögeleri seçin, diğerleri gizlenecektir + Yan Menü Ögelerini Düzenle + Sayfalar arası geçiş için tıklayın + Odaklanınca yan menü sayfalarını değiştir + Uyuyakalma Koruması + Tema müziğini oynat + Oynatmayı geçersiz kılanlar + Seçili sekmeleri hatırla + Sıradaki bölümlerde tekrar izlemeyi etkinleştir + İlerleme çubuğu adımları + Hata ayıklama için yararlıdır + Uygulama günlüklerini mevcut sunucuya gönder + Son bağlanan sunucuya gönderilmeye çalışılacak + Hata raporlarını gönder + Ayarlar + Devam ederken videoyu geri sar + Geri atla + Reklam geçme davranışı + İleri atla + İntro atlama davranışı + Kapanış jeneriği atlama davranışı + Önizlemeyi atlama davranışı + Özet atlama davranışı + Güncelleme mevcut + Uygulama güncellemelerini kontrol etmek için kullanılan URL + Güncelleme URL\'si + Yazı tipi boyutu + Yazı tipi rengi + Yazı tipi opaklığı + Kenar stili + Kenar rengi + Arka plan opaklığı + Arka plan stili + Altyazı stili + Arka plan rengi + Sıfırla + Kalın yazı tipi + Oynatıcı motoru + MPV: Donanım kod çözmeyi kullan + Çökme sorunu yaşıyorsanız devre dışı bırakın + MPV Ayarları + ExoPlayer Ayarları + Bölümü atla + Reklamları atla + Önizlemeyi atla + Özeti atla + Kapanış jeneriğini atla + İntroyu atla + Oynatıldı + Filtre + Yıl + Dönem + Sil + MPV: gpu-next kullan + mpv.conf dosyasını düzenle + Değişiklikleri iptal et? + Kenar boşluğu + Ayrıntılı günlükleme + PIN girin + Otomatik oturum aç + Sunucu üzerinden giriş yap + Onaylamak için orta tuşa basın + Profil için PIN iste + PIN\'i onayla + Hatalı + PIN en az 4 haneli olmalıdır + PIN kaldırılacak + Görüntü disk önbellek boyutu (MB) + Görünüm seçenekleri + Sütun sayısı + Öğe aralığı + En Boy Oranı + Detayları göster + Görsel türü + Dolby Vision + Dolby Atmos + + Konuk oyuncular + Kenar boyutu + Yenileme hızı değiştirme + Otomatik + Kullanıcı adı/şifre kullan + Oturum aç + Genel + Kapsayıcı + Başlık + Codec + Profil + Düzey + Çözünürlük + Anamorfik + Geçmeli + Kare hızı + Bit derinliği + Video aralığı + Video aralık türü + Renk alanı + Renk aktarımı + Renk ön seçimleri + Piksel biçimi + Referans kareler + NAL + Dil + Düzen + Kanallar + Örnekleme hızı + AVC + Evet + Hayır + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Başlıkları göster + Tekrarla + Favori kanalları en başta göster + Kanalları son izlenene göre sırala + Programları türüne göre renklendir + Altyazı gecikmesi + Görsel arka plan stili + Çözünürlük değiştirme + Lokal + Fragmanı oynat + Fragman yok + Parça seçimlerini temizle + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Dolby Vision Profil 7\'yi doğrudan oynat + Cihaz uyumluluk kontrollerini yoksay + Keşfet + İstek + Beklemede + Seerr entegrasyonu + Seerr Sunucusunu Kaldır + Seerr sunucusu eklendi + Şifre + Kullanıcı adı + URL + Popüler + Yaklaşan Filmler + Yaklaşan Diziler + 4K olarak iste + AV1 yazılımsal kod çözme + HDR içerikler hariç varsayılan oynatıcı artık MPV. \nBu ayarı ayarlar bölümünden değiştirebilirsiniz. + From dd61bab8d11dd6030878a60958ff0dc6158db9a1 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 4 Feb 2026 23:35:42 +0000 Subject: [PATCH 030/176] Translated using Weblate (Italian) Currently translated at 100.0% (364 of 364 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 15a70f3d..3f1af57f 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -422,4 +422,6 @@ Riconoscimento vocale scaduto Decodifica software AV1 MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni. + Stile sottotitoli HDR + Opacità sottotitoli immagine From 231dc4e6df3706ec59408140052e5f1507586a32 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 5 Feb 2026 00:36:30 +0000 Subject: [PATCH 031/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (364 of 364 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 10a25eac..b8bf2ad0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -390,4 +390,6 @@ 重试 AV1 软件解码 除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。 + HDR 字幕样式 + 图像字幕不透明度 From 2a264f31e6c81a53a052c6c0c742cf171f3046e9 Mon Sep 17 00:00:00 2001 From: damontecres Date: Thu, 5 Feb 2026 01:04:53 +0000 Subject: [PATCH 032/176] Translated using Weblate (Spanish) Currently translated at 94.2% (361 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 42 +++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 2b24e4c2..0fa6e76f 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -66,8 +66,8 @@ Tráiler TráilerTráilers - - + + Series Interfaz @@ -86,8 +86,8 @@ Extras OtroOtros - - + + Configuración avanzada Interfaz avanzada @@ -165,13 +165,13 @@ Cursiva Detrás de cámarasDetrás de cámaras - - + + Canción principalCanciones principales - - + + Vídeos temáticos @@ -180,28 +180,28 @@ ClipClips - - + + Escena eliminadaEscenas eliminadas - - + + EntrevistaEntrevistas - - + + EscenaEscenas - - + + MuestraMuestras - - + + Mini-documentales @@ -210,13 +210,13 @@ CortoCortos - - + + %s descarga %s descargas - + %d hora From 01bc279ecfab50e2ae50e7ec3c8ab547acb67f7f Mon Sep 17 00:00:00 2001 From: oyvhov Date: Thu, 5 Feb 2026 01:04:57 +0000 Subject: [PATCH 033/176] =?UTF-8?q?Translated=20using=20Weblate=20(Norwegi?= =?UTF-8?q?an=20Bokm=C3=A5l)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 83.2% (319 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/nb_NO/ --- app/src/main/res/values-nb-rNO/strings.xml | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5b39797f..d892ebf3 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -122,7 +122,7 @@ Trailer Trailere - + DVR-plan Programoversikt @@ -155,47 +155,47 @@ Ekstramateriale Annet - + Bak kulissene - + Temasanger - + Temavideoer - + Klipp - + Slettede scener - + Intervjuer - + Scener - + Smakebiter - + Bakomfilmer - + Kortfilmer - + %s nedlasting From 24cd367204a6da19b115e733da586a89224e4207 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 5 Feb 2026 01:45:42 +0000 Subject: [PATCH 034/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 94.7% (363 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 06a641bc..a3de771f 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -160,7 +160,7 @@ 確認 繼續觀看 影評人評分 - %.1f 秒 + %.2f 秒 預設 刪除 去世 @@ -390,4 +390,5 @@ 重試 AV1 軟體解碼 除了 HDR 影片外,MPV 已成為預設播放器。 \n您可以在設定中變更此設定。 + HDR 字幕樣式 From a8ac91ecd1e9e166d3fbd18f050d79667d5e5dda Mon Sep 17 00:00:00 2001 From: ictu Date: Thu, 5 Feb 2026 01:04:59 +0000 Subject: [PATCH 035/176] Translated using Weblate (Polish) Currently translated at 63.1% (242 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pl/ --- app/src/main/res/values-pl/strings.xml | 42 +++++++++++++------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 69a8f90a..53cd49fc 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -45,9 +45,9 @@ Zwiastun Zwiastuny - - - + + + Potwierdź Data Wydania @@ -122,21 +122,21 @@ Wciśnij środek by pauzować/wznowić Wywiady - - - + + + Sceny - - - + + + Czołówki - - - + + + Utwórz nową playliste Dodaj do playlisty @@ -182,9 +182,9 @@ Czcionka kursywy Za kulisami - - - + + + Ustawienia zaawansowane Sprawdź dostępność aktualizacji @@ -270,15 +270,15 @@ Dodatki Inne - - - + + + Usunięte sceny - - - + + + Naciśnij by zmienić stronę Przydatne do debugowania From eea6003ffbf4061a9466de4ea53b58e762368552 Mon Sep 17 00:00:00 2001 From: efko Date: Thu, 5 Feb 2026 01:04:53 +0000 Subject: [PATCH 036/176] Translated using Weblate (Greek) Currently translated at 46.7% (179 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/el/ --- app/src/main/res/values-el/strings.xml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4d7b297d..6159ff33 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -171,35 +171,35 @@ Επιπρόσθετα Άλλο - + Παρασκήνια - + Αποσπάσματα - + Διαγραμμένες σκηνές - + Συνεντεύξεις - + Σκηνές - + Δείγματα - + Χαρακτηριστικά - + Αυτόματος έλεγχος για ενημερώσεις Καθυστέρηση πριν την έναρξη του επόμενου From 735d413a74d020e6dc77b0eb147c1bcea4b02438 Mon Sep 17 00:00:00 2001 From: idezentas Date: Thu, 5 Feb 2026 01:05:01 +0000 Subject: [PATCH 037/176] Translated using Weblate (Turkish) Currently translated at 94.2% (361 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 30 +++++++++++++------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 7ba907c2..d3d524cc 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -66,7 +66,7 @@ Diğer Filmler - + Ad Sıradaki bölümler @@ -81,7 +81,7 @@ Yol Kişiler - + Oynatma Sayısı Buradan oynat @@ -152,7 +152,7 @@ Fragman Fragmanlar - + Kayıt Takvimi Rehber @@ -160,7 +160,7 @@ Sezonlar Diziler - + Arayüz Bilinmiyor @@ -188,47 +188,47 @@ Ekstralar Diğer Ekstralar - + Sahne Arkası - + Tema şarkıları - + Tema Videoları - + Klipler - + Silinmiş Sahneler - + Röportajlar - + Sahneler - + Örnekler - + Tanıtımlar - + Kısa Videolar - + %s indirme From f5e0607ae9e54ca5fe807438ca3e7dd09f884afc Mon Sep 17 00:00:00 2001 From: SimonHung Date: Thu, 5 Feb 2026 03:08:44 +0000 Subject: [PATCH 038/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 24 ++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a3de771f..330f5dd0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -119,10 +119,10 @@ 更新的 URL 字體大小 字體顏色 - 字體透明度 + 字體不透明度 邊框樣式 邊框顏色 - 背景透明度 + 背景不透明度 背景樣式 字幕樣式 背景顏色 @@ -391,4 +391,24 @@ AV1 軟體解碼 除了 HDR 影片外,MPV 已成為預設播放器。 \n您可以在設定中變更此設定。 HDR 字幕樣式 + 對比 + 飽和度 + 色調 + 紅色 + 綠色 + 藍色 + 模糊 + 存為相簿 + 播放幻燈片 + 停播幻燈片 + 從頭開始 + 已無更多照片 + 向左旋轉 + 向右旋轉 + 放大 + 縮小 + 幻燈片播放時間 + 幻燈片播放時播放影片 + 圖形字幕不透明度 + 亮度 From a96b0b428d5df283d2d8d649b2f0afc2725e9b12 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 5 Feb 2026 02:27:00 +0000 Subject: [PATCH 039/176] Translated using Weblate (Italian) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 3f1af57f..37b386db 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -104,7 +104,7 @@ Guida TV Mostra Ricerca in corso… - %.1f secondi + %.2f secondi Commerciale Valutazione community Valutazione della critica @@ -424,4 +424,23 @@ MPV è ora il lettore predefinito, eccetto per i contenuti HDR.\nPuoi modificarlo nelle impostazioni. Stile sottotitoli HDR Opacità sottotitoli immagine + Luminosità + Contrasto + Saturazione + Tinta + Rosso + Verde + Blu + Sfocatura + Salva per l\'album + Riproduci slideshow + Ferma slideshow + Dall\'inizio + Nessun\'altra foto + Ruota a sinistra + Ruota a destra + Ingrandisci + Rimpicciolisci + Durata slideshow + Riproduci video durante lo slideshow From 03f57f4679106fe5d231959c925e8be161178c07 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 5 Feb 2026 02:09:58 +0000 Subject: [PATCH 040/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b8bf2ad0..e0daaac7 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,7 +23,7 @@ 确认 继续观看 影评人评分 - %.1f 秒 + %.2f 秒 默认 删除 去世 @@ -392,4 +392,23 @@ 除 HDR 视频外,mpv 现在是默认播放器。\n您可以在设置中更改此设置。 HDR 字幕样式 图像字幕不透明度 + 亮度 + 对比度 + 饱和度 + 色调 + 红色 + 绿色 + 蓝色 + 模糊 + 保存到相册 + 播放幻灯片 + 停止幻灯片 + 回到开头 + 没有更多照片 + 向左旋转 + 向右旋转 + 放大 + 缩小 + 幻灯片时长 + 幻灯片播放期间播放视频 From bdac869402526d86d68cf7e5a4012fd57646fb5d Mon Sep 17 00:00:00 2001 From: Sathen Date: Thu, 5 Feb 2026 11:04:15 +0000 Subject: [PATCH 041/176] Translated using Weblate (Ukrainian) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index dce8332a..ea7e0323 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -23,7 +23,7 @@ Підтвердити Продовжити перегляд Оцінка критиків - %.1fсекунд + %.1f секунд За замовчуванням Видалити Помер @@ -453,4 +453,25 @@ Запит в 4К Програмне декодування AV1 MPV тепер є плеєром за замовчуванням, за винятком для HDR.\nВи можете змінити це в налаштуваннях. + HDR субтитри + Прозорість зображення заголовку + Яскравість + Контрастність + Насичення + Відтінок + Червоний + Зелений + Синій + Розмиття + Зберегти до альбому + Відтворити слайд-шоу + Зупинити слайд-шоу + На початок + Фото більше немає + Повернути ліворуч + Повернути праворуч + Збільшити + Зменшити + Тривалість слайд-шоу + Відтворення відео під час слайд-шоу From 0f33f14972b729eb718b7781f9d3d9db469e6dbc Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 7 Feb 2026 13:03:48 +0000 Subject: [PATCH 042/176] Translated using Weblate (Czech) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 4ed1e15c..237ae6db 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -22,7 +22,7 @@ Potvrdit Pokračovat ve sledování Hodnocení kritiků - %.1f sekund + %.2f sekund Výchozí Smazat Zemřel @@ -453,4 +453,25 @@ Prokládané Color code programs MPV je nyní výchozím přehrávačem mimo HDR.\nToto můžete změnit v nastavení. + Styl HDR titulků + Průhlednost obrázku titulků + Jas + Kontrast + Sytost + Odstín + Červená + Zelená + Modrá + Rozostření + Uložit do alba + Přehrát prezentaci + Zastavit prezentaci + Na začátek + Žádné další fotky + Otočit vlevo + Otočit vpravo + Přiblížit + Oddálit + Trvání prezentace + Přehrávat videa během prezentace From 21010df2aa697d144b6d2f1b54526567da0edf9b Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 7 Feb 2026 17:51:11 +0000 Subject: [PATCH 043/176] Translated using Weblate (Turkish) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index d3d524cc..745da44f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -23,7 +23,7 @@ Onayla İzlemeye devam et Eleştirmen puanı - %.1f saniye + %.2f saniye Varsayılan Sil Ölüm Tarihi @@ -34,7 +34,7 @@ İndiriliyor… Etkin - Bitiş zamanı %1$s + Bitiş saati %1$s Sunucu IP adresini veya URL\'sini girin Sunucu adresini girin Bölümler @@ -415,4 +415,25 @@ 4K olarak iste AV1 yazılımsal kod çözme HDR içerikler hariç varsayılan oynatıcı artık MPV. \nBu ayarı ayarlar bölümünden değiştirebilirsiniz. + HDR Altyazı Stili + Altyazı Arka Plan Matlığı + Parlaklık + Kontrast + Doygunluk + Ton + Kırmızı + Yeşil + Mavi + Bulanıklık + Albüme Kaydet + Slayt Gösterisini Oynat + Slayt Gösterisini Durdur + En baştan + Daha fazla fotoğraf yok + Sola döndür + Sağa döndür + Yakınlaştır + Uzaklaştır + Slayt Gösterisi Süresi + Slayt gösterisi sırasında videoları oynat From a62b34d3726267d6f67fb116331d9cfdf299b4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 8 Feb 2026 10:36:19 +0000 Subject: [PATCH 044/176] Translated using Weblate (Estonian) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 765c5fe2..7288105f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -22,7 +22,7 @@ Kinnita Jätka vaatamist Kriitikute hinnang - %.1f sekundit + %.2f sekundit Vaikimisi Kustuta Surmaaeg @@ -406,4 +406,25 @@ Proovi uuesti AV1 tarkvaraline dekodeerimine MPV on nüüd vaikimisi meediaesitaja kõige v.a. HDR-i jaoks.\nSeda saad muuta seadistustest. + HDR-i tiitrite stiil + Pildiliste tiitrite läbipaistvus + Eredus + Kontrastsus + Küllastus + Värvitoon + Punane + Roheline + Sinine + Hägustamine + Salvesta albumi jaoks + Näita slaidiesitlust + Peata slaidiesitlus + Alguses + Rohkem fotosid pole + Pööra vasakule + Pööra paremale + Suumi sisse + Suumi välja + Slaidiesitluse kestus + Slaidiesitluse ajal esita ka videoid From 70ea593bd9fe4b042f61c52a8f9e27ff9152c4c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Sun, 8 Feb 2026 10:36:36 +0000 Subject: [PATCH 045/176] Translated using Weblate (Estonian) Currently translated at 100.0% (383 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 7288105f..482554a0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -426,5 +426,5 @@ Suumi sisse Suumi välja Slaidiesitluse kestus - Slaidiesitluse ajal esita ka videoid + Slaidiesitluse ajal esita videoid From cd540c621b4c5808067c52e1c09b137652acd84c Mon Sep 17 00:00:00 2001 From: Bliiitz Date: Sun, 8 Feb 2026 15:48:40 +0000 Subject: [PATCH 046/176] Translated using Weblate (French) Currently translated at 99.7% (382 of 383 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a53c580f..c131a379 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -422,4 +422,25 @@ Demande en 4K Décodage logiciel AV1 MPV est désormais le lecteur par défaut, sauf pour HDR.\nVous pouvez modifier cela dans les paramètres. + Style de sous-titres HDR + opacité du sous-titre de l\'image + Luminosité + Contraste + Saturation + Teinte + Rouge + Vert + Bleu + Flou + Enregistrer pour l\'album + Lire le diaporama + Arrêter le diaporama + Au début + Plus de photos + Faire pivoter à gauche + Faire pivoter à droite + Zoomer + Dézoomer + Durée du diaporama + Diffuser des vidéos pendant le diaporama From 05bc8b67d131171cdfc5fd1fdce73ad34199d863 Mon Sep 17 00:00:00 2001 From: Lorixx Date: Mon, 9 Feb 2026 19:43:04 +0000 Subject: [PATCH 047/176] Translated using Weblate (German) Currently translated at 92.1% (354 of 384 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 5cec8ebf..eddd471d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -404,4 +404,17 @@ Randgröße Auflösungswechsel Gaststars + Ab Anfang + keine Fotos + Links Rotieren + Rechts Rotieren + Rot + Grün + Blau + Verschwommen + Speichern für Album + Diashow abspielen + Diashow anhalten + Helligkeit + Kontrast From b51052acbb89f697f770343f3fbd05172f1f549c Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 9 Feb 2026 04:56:58 +0000 Subject: [PATCH 048/176] Translated using Weblate (Indonesian) Currently translated at 100.0% (384 of 384 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 6f8f49c7..03fd5a93 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -23,7 +23,7 @@ Konfirmasi Lanjutkan menonton Penilaian Kritis - %.1f detik + %.2f detik Bawaan Hapus Meninggal @@ -390,4 +390,26 @@ Tertunda Dekode perangkat lunak AV1 MPV sekarang adalah pemutar bawaan kecuali konten HDR.\nPerubahan dapat dilakukan di menu pengaturan. + Gaya subtitel HDR + Opasitas subtitel gambar + Kecerahan + Kontras + Saturasi + Hue + Merah + Hijau + Biru + Kabur + Simpan ke album + Putar tayangan slide + Hentikan tayangan slide + Di awal + Tidak ada foto lagi + Putar kiri + Putar kanan + Perbesar + Perkecil + Durasi tayangan slide + Putar video selama tayangan slide + Kirim log info media ke server From bdebe38b60afaf20abb0566612ccf0d61f973d32 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Mon, 9 Feb 2026 17:18:53 +0000 Subject: [PATCH 049/176] Translated using Weblate (Italian) Currently translated at 100.0% (384 of 384 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 37b386db..e5aa51b4 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -443,4 +443,5 @@ Rimpicciolisci Durata slideshow Riproduci video durante lo slideshow + Invia log informazioni media al server From c01235d183b58b0548adb8ec65c9a86298cab84d Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Mon, 9 Feb 2026 03:34:54 +0000 Subject: [PATCH 050/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (384 of 384 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e0daaac7..e65b55b4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -231,10 +231,10 @@ 将尝试发送到最后连接的服务器 发送崩溃报告 设置 - 恢复播放时快退 - 快退 + 恢复播放时自动向后跳过 + 向后跳过 跳过广告方式 - 快进 + 向前跳过 跳过片头方式 跳过片尾方式 跳过预览方式 @@ -411,4 +411,5 @@ 缩小 幻灯片时长 幻灯片播放期间播放视频 + 将媒体信息日志发送到服务器 From 2cfd1308531e5205597caa5c529e9329b0907a69 Mon Sep 17 00:00:00 2001 From: Fjuro Date: Mon, 9 Feb 2026 18:10:03 +0000 Subject: [PATCH 051/176] Translated using Weblate (Czech) Currently translated at 100.0% (384 of 384 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 237ae6db..1a328c33 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -474,4 +474,5 @@ Oddálit Trvání prezentace Přehrávat videa během prezentace + Odesílat protokol informací o médiích serveru From f539f69bb603e6f03fdea41e9b494938888e72dc Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Tue, 10 Feb 2026 01:17:07 +0000 Subject: [PATCH 052/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e65b55b4..b08bfeb5 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -412,4 +412,9 @@ 幻灯片时长 幻灯片播放期间播放视频 将媒体信息日志发送到服务器 + + %s 天 + + 无限制 + 即将播放中的最大天数 From 9f2ca8ca18741f30013e5fbca2381aadaf2dbd80 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Tue, 10 Feb 2026 18:53:50 +0000 Subject: [PATCH 053/176] Translated using Weblate (Portuguese) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 31 +++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3205d05d..c2ee4aec 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -13,7 +13,7 @@ Colecção Colecções Confirmar - %.1f segundos + %.2f segundos Padrão Eliminar Realizado por %1$s @@ -422,4 +422,33 @@ Tentar novamente Decodificação de AV1 por software O MPV é agora o reprodutor padrão, exceto para HDR.\nPode alterar esta configuração nas definições. + + %s Dia + %s Dias + %s Dias + + Estilo de legenda HDR + Transparência da legenda da imagem + Brilho + Contraste + Saturação + Matiz + Vermelho + Verde + Azul + Desfoque + Guardar para o álbum + Reproduzir slides + Parar slides + No início + Não há mais fotos + Rodar para a esquerda + Rodar para a direita + Ampliar + Diminuir + Duração dos slides + Reproduzir vídeos durante a apresentação de slides + Enviar registo de informações de mídia para o servidor + Sem limite + Dias máximos em \'A Seguir\' From 073f030d2030d447eadacc70383b78280d211cb6 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 10 Feb 2026 15:14:08 -0500 Subject: [PATCH 054/176] Fix spanish plurals --- app/src/main/res/values-es/strings.xml | 34 +++++++++++++------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 0fa6e76f..31445b5a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -65,8 +65,8 @@ Cambiar Tráiler - TráilerTráilers - + Tráiler + Tráilers Series @@ -85,8 +85,8 @@ Duración Extras - OtroOtros - + Otro + Otros Configuración avanzada @@ -164,13 +164,13 @@ Orden de emisión Cursiva - Detrás de cámarasDetrás de cámaras + Detrás de cámaras - Canción principalCanciones principales - + Canción principal + Canciones principales @@ -179,28 +179,28 @@ - ClipClips - + Clip + Clips - Escena eliminadaEscenas eliminadas - + Escena eliminada + Escenas eliminadas - EntrevistaEntrevistas - + Entrevista + Entrevistas - EscenaEscenas - + Escena + Escenas - MuestraMuestras - + Muestra + Muestras From 724d4d0da31b66bcf06f7c3f08773077c9b293db Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 10 Feb 2026 15:55:45 -0500 Subject: [PATCH 055/176] Release v0.4.2 From 6e676b9474f0f97e3e070251a033db536f417f07 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:05:11 -0500 Subject: [PATCH 056/176] Fixes overscrolling on recommended tabs (#881) ## Description If the continue watching row is empty on recommended pages, it would scroll to the first successfully loaded row even if previous ones are still pending. This PR fixes that so the page waits until the right first row is ready. ### Related issues Fixes #839 ### Testing Emulator & shield 2019 ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/RecommendedContent.kt | 10 ++++---- .../ui/components/RecommendedMovie.kt | 23 +++++++++++++++---- .../ui/components/RecommendedTvShow.kt | 21 +++++++++++++---- .../damontecres/wholphin/util/LoadingState.kt | 3 +++ 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 24b3d502..6c577c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -37,9 +37,10 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch import org.jellyfin.sdk.model.api.MediaType import java.util.UUID @@ -99,13 +100,13 @@ abstract class RecommendedViewModel( abstract fun update( @StringRes title: Int, row: HomeRowLoadingState, - ) + ): HomeRowLoadingState fun update( @StringRes title: Int, block: suspend () -> List, - ) { - viewModelScope.launch(Dispatchers.IO) { + ): Deferred = + viewModelScope.async(Dispatchers.IO) { val titleStr = context.getString(title) val row = try { @@ -115,7 +116,6 @@ abstract class RecommendedViewModel( } update(title, row) } - } } @Composable 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 e44c72f7..a1a5be80 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 @@ -32,6 +32,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull @@ -123,6 +124,8 @@ class RecommendedMovieViewModel } } + val jobs = mutableListOf>() + update(R.string.recently_released) { val request = GetItemsRequest( @@ -138,7 +141,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) update(R.string.recently_added) { val request = @@ -155,7 +158,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) update(R.string.top_unwatched) { val request = @@ -173,7 +176,7 @@ class RecommendedMovieViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, false) - } + }.also(jobs::add) viewModelScope.launch(Dispatchers.IO) { try { @@ -216,8 +219,17 @@ class RecommendedMovieViewModel } } + // If the continue watching row is empty, then wait until the first successful row + // is loaded before telling the UI that the page is loaded if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { 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 4e952033..00430a2c 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 @@ -33,6 +33,7 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow @@ -170,6 +171,8 @@ class RecommendedTvShowViewModel } } + val jobs = mutableListOf>() + update(R.string.recently_released) { val request = GetItemsRequest( @@ -185,7 +188,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) update(R.string.recently_added) { val request = @@ -202,7 +205,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) update(R.string.top_unwatched) { val request = @@ -220,7 +223,7 @@ class RecommendedTvShowViewModel enableTotalRecordCount = false, ) GetItemsRequestHandler.execute(api, request).toBaseItems(api, true) - } + }.also(jobs::add) viewModelScope.launch(Dispatchers.IO) { try { @@ -264,7 +267,14 @@ class RecommendedTvShowViewModel } if (loading.value == LoadingState.Loading || loading.value == LoadingState.Pending) { - loading.setValueOnMain(LoadingState.Success) + for (i in 0.. current.toMutableList().apply { set(rowTitles[title]!!, row) } } + return row } companion object { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index c35e9512..1a4ca1aa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -48,6 +48,9 @@ sealed interface RowLoadingState { sealed interface HomeRowLoadingState { val title: String + val completed: Boolean + get() = this is Success || this is Error + data class Pending( override val title: String, ) : HomeRowLoadingState From 83e5a44f37ef8ff2e3767b108332b82b27b81936 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:07:22 -0500 Subject: [PATCH 057/176] Fixes glitches when scrolling on the home page (#882) ## Description Fixes the possible glitch on some devices when scrolling left-right on rows on the home page ### Related issues Fixes #367 Fixes #872 ### Testing Emulator & shield 2019 ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 61 ++-- .../damontecres/wholphin/ui/main/HomePage.kt | 283 ++++++++++-------- .../ui/util/ScrollToTopBringIntoViewSpec.kt | 44 +++ 3 files changed, 236 insertions(+), 152 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index 691b00cb..97b974ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -1,6 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import android.content.Context +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -23,6 +26,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -47,6 +51,7 @@ import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.DataLoadingState import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel @@ -186,6 +191,7 @@ data class DiscoverState( val upcomingTv: DiscoverRowData = DiscoverRowData.EMPTY, ) +@OptIn(ExperimentalFoundationApi::class) @Composable fun SeerrDiscoverPage( preferences: UserPreferences, @@ -249,28 +255,41 @@ fun SeerrDiscoverPage( .padding(top = 24.dp, bottom = 16.dp, start = 32.dp) .fillMaxHeight(.25f), ) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), - modifier = - Modifier - .focusRestorer() - .fillMaxSize(), + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(rows) { rowIndex, row -> - DiscoverRow( - row = row, - onClickItem = { index, item -> - position = RowColumn(rowIndex, index) - viewModel.navigationManager.navigateTo(item.destination) - }, - onLongClickItem = { index, item -> }, - onCardFocus = { index -> position = RowColumn(rowIndex, index) }, - focusRequester = focusRequesters[rowIndex], - modifier = - Modifier - .fillMaxWidth(), - ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 40.dp), + modifier = + Modifier + .focusRestorer() + .fillMaxSize(), + ) { + itemsIndexed(rows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + DiscoverRow( + row = row, + onClickItem = { index, item -> + position = RowColumn(rowIndex, index) + viewModel.navigationManager.navigateTo(item.destination) + }, + onLongClickItem = { index, item -> }, + onCardFocus = { index -> position = RowColumn(rowIndex, index) }, + focusRequester = focusRequesters[rowIndex], + modifier = + Modifier + .fillMaxWidth(), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cfb13ff7..e41acd1b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.main +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,6 +21,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -34,6 +37,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -69,6 +73,7 @@ import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.delay @@ -183,6 +188,7 @@ fun HomePage( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomePageContent( homeRows: List, @@ -223,14 +229,15 @@ fun HomePageContent( } } } - LaunchedEffect(position) { - if (position.row >= 0) { - listState.animateScrollToItem(position.row) - } - } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -240,134 +247,148 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) - LazyColumn( - state = listState, - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = - PaddingValues( - bottom = Cards.height2x3, - ), - modifier = - Modifier - .focusRestorer(), + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + CompositionLocalProvider( + LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), ) { - itemsIndexed(homeRows) { rowIndex, row -> - when (val r = row) { - is HomeRowLoadingState.Loading, - is HomeRowLoadingState.Pending, - -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } - } - - is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - - is HomeRowLoadingState.Success -> { - if (row.items.isNotEmpty()) { - ItemRow( - title = row.title, - items = row.items, - onClickItem = { index, item -> - onClickItem.invoke(RowColumn(rowIndex, index), item) - }, - onLongClickItem = { index, item -> - onLongClickItem.invoke(RowColumn(rowIndex, index), item) - }, - modifier = - Modifier - .fillMaxWidth() - .focusGroup() - .focusRequester(rowFocusRequesters[rowIndex]) - .animateItem(), - cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, - item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, - onClick = onClick, - onLongClick = onLongClick, - modifier = - cardModifier - .onFocusChanged { - if (it.isFocused) { - position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) - } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), - ) - } - }.onKeyEvent { - if (isPlayKeyUp(it) && item?.type?.playable == true) { - Timber.v("Clicked play on ${item.id}") - onClickPlay.invoke(position, item) - return@onKeyEvent true - } - return@onKeyEvent false - }, - interactionSource = null, - cardHeight = Cards.height2x3, + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = + PaddingValues( + bottom = Cards.height2x3, + ), + modifier = + Modifier + .focusRestorer(), + ) { + itemsIndexed(homeRows) { rowIndex, row -> + CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + when (val r = row) { + is HomeRowLoadingState.Loading, + is HomeRowLoadingState.Pending, + -> { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, ) - }, - ) + Text( + text = stringResource(R.string.loading), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + } + + is HomeRowLoadingState.Error -> { + var focused by remember { mutableStateOf(false) } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .onFocusChanged { + focused = it.isFocused + }.focusable() + .background( + if (focused) { + // Just so the user can tell it has focus + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Unspecified + }, + ).animateItem(), + ) { + Text( + text = r.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = r.localizedMessage, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + + is HomeRowLoadingState.Success -> { + if (row.items.isNotEmpty()) { + ItemRow( + title = row.title, + items = row.items, + onClickItem = { index, item -> + onClickItem.invoke(RowColumn(rowIndex, index), item) + }, + onLongClickItem = { index, item -> + onLongClickItem.invoke( + RowColumn(rowIndex, index), + item, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusGroup() + .focusRequester(rowFocusRequesters[rowIndex]) + .animateItem(), + cardContent = { index, item, cardModifier, onClick, onLongClick -> + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = AspectRatios.TALL, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = + cardModifier + .onFocusChanged { + if (it.isFocused) { + position = + RowColumn(rowIndex, index) +// item?.let(onUpdateBackdrop) + } + if (it.isFocused && onFocusPosition != null) { + val nonEmptyRowBefore = + homeRows + .subList(0, rowIndex) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition.invoke( + RowColumn( + rowIndex - nonEmptyRowBefore, + index, + ), + ) + } + }.onKeyEvent { + if (isPlayKeyUp(it) && item?.type?.playable == true) { + Timber.v("Clicked play on ${item.id}") + onClickPlay.invoke( + position, + item, + ) + return@onKeyEvent true + } + return@onKeyEvent false + }, + interactionSource = null, + cardHeight = Cards.height2x3, + ) + }, + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt new file mode 100644 index 00000000..73f22de6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/ScrollToTopBringIntoViewSpec.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui.util + +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec +import androidx.compose.foundation.lazy.LazyColumn + +/** + * Overrides scrolling so that the item being scrolled to is at the top of the view offset by the provided pixels + * + * Note: the offset is necessary for anything that is focuseable, but has content before (eg a title) that needs to be displayed too + * + * Note: this applies to ALL scrollable composables within its scope, so a [LazyColumn] of [androidx.compose.foundation.lazy.LazyRow]s likely needs nested [LocalBringIntoViewSpec] overrides + * + * Example: + * ```kotlin + * val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + * CompositionLocalProvider(LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx)) { + * LazyColumn{ + * items(list){ + * CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + * // Content + * } + * } + * } + * } + * ``` + */ +class ScrollToTopBringIntoViewSpec( + val spaceAbovePx: Float = 100f, +) : BringIntoViewSpec { + override fun calculateScrollDistance( + offset: Float, + size: Float, + containerSize: Float, + ): Float { +// Timber.v( +// "calculateScrollDistance: offset=%s, size=%s, containerSize=%s", +// offset, +// size, +// containerSize, +// ) + return offset - spaceAbovePx + } +} From 028553dc911ed8d70b879154f9b429f8d17945f5 Mon Sep 17 00:00:00 2001 From: voc0der Date: Thu, 12 Feb 2026 01:06:03 +0000 Subject: [PATCH 058/176] Add Quick Connect authorization to Settings (#820) ## Description - Adding `Quick Connect` drawer to the Settings under the Seer integration, and before Additional options. - When interacted with, A inline-dialog appears (after internally resetting itself), with gutter options Cancel / OK, like existing dialogs, and when that is completed, where it completes the other side of `Quick Connect`, e.g. login to your phone Jellyfin app by using Wholphin. - Failure and success both are indicated to the user and if success, it closes the window. ### Related issues Related to #819 Closes #819 ### Testing Tested on development build from my repo on a Nvidia Shield 2019. Performed several logout/login, both success and fail to observe dialog state and function. ## AI or LLM usage Used Claude Sonnet 4.5 for most of the code, ChatGPT, reviewed it, then I reviewed it, made a few tweaks. --- .../wholphin/data/ServerRepository.kt | 12 ++ .../wholphin/preferences/AppPreference.kt | 9 ++ .../ui/preferences/PreferencesContent.kt | 48 +++++++ .../ui/preferences/PreferencesViewModel.kt | 27 ++++ .../ui/preferences/QuickConnectDialog.kt | 127 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + 6 files changed, 228 insertions(+) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index f66dcfca..f013525b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi import org.jellyfin.sdk.api.client.extensions.systemApi import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.model.api.AuthenticationResult @@ -265,6 +266,17 @@ class ServerRepository } } + suspend fun authorizeQuickConnect(code: String): Boolean = + withContext(Dispatchers.IO) { + val userId = currentUser.value?.id + if (userId == null) { + Timber.e("No user logged in for Quick Connect authorization") + throw IllegalStateException("Must be logged in to authorize Quick Connect") + } + val response = apiClient.quickConnectApi.authorizeQuickConnect(code, userId) + response.content + } + companion object { fun getServerSharedPreferences(context: Context): SharedPreferences = context.getSharedPreferences( 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 4d7dd76f..4c17ac07 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 @@ -943,6 +943,14 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val QuickConnect = + AppClickablePreference( + title = R.string.quick_connect, + summary = R.string.quick_connect_summary, + getter = { }, + setter = { prefs, _ -> prefs }, + ) + val SlideshowDuration = AppSliderPreference( title = R.string.slideshow_duration, @@ -1168,6 +1176,7 @@ val advancedPreferences = title = R.string.more, preferences = listOf( + AppPreference.QuickConnect, AppPreference.SendAppLogs, AppPreference.SendCrashReports, AppPreference.DebugLogging, 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 dcbda2fc..29fa873e 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 @@ -93,6 +93,7 @@ fun PreferencesContent( var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } + var showQuickConnectDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { viewModel.preferenceDataStore.data.collect { @@ -413,6 +414,22 @@ fun PreferencesContent( ) } + AppPreference.QuickConnect -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (currentUser != null) { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = true + } + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -506,6 +523,37 @@ fun PreferencesContent( SeerrDialogMode.None -> {} } } + + if (showQuickConnectDialog) { + val quickConnectStatus by viewModel.quickConnectStatus.collectAsState(LoadingState.Pending) + val successMessage = stringResource(R.string.quick_connect_success) + + LaunchedEffect(quickConnectStatus) { + when (val status = quickConnectStatus) { + LoadingState.Success -> { + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + showQuickConnectDialog = false + } + + is LoadingState.Error -> { + val errorMessage = status.message ?: "Authorization failed" + Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show() + } + + else -> {} + } + } + + QuickConnectDialog( + onSubmit = { code -> + viewModel.authorizeQuickConnect(code) + }, + onDismissRequest = { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = false + }, + ) + } } @Composable diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 9973bbec..dd7a8e79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -24,9 +24,12 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo @@ -61,6 +64,9 @@ class PreferencesViewModel seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId } + private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending) + val quickConnectStatus: StateFlow = _quickConnectStatus + init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> @@ -123,6 +129,27 @@ class PreferencesViewModel } } + fun resetQuickConnectStatus() { + _quickConnectStatus.value = LoadingState.Pending + } + + fun authorizeQuickConnect(code: String) { + viewModelScope.launchIO { + _quickConnectStatus.value = LoadingState.Loading + try { + val success = serverRepository.authorizeQuickConnect(code) + _quickConnectStatus.value = + if (success) { + LoadingState.Success + } else { + LoadingState.Error("Authorization failed") + } + } catch (e: Exception) { + _quickConnectStatus.value = LoadingState.Error(e) + } + } + } + companion object { suspend fun resetSubtitleSettings(appPreferences: DataStore) { appPreferences.updateData { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt new file mode 100644 index 00000000..dfad49b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/QuickConnectDialog.kt @@ -0,0 +1,127 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.EditTextBox +import com.github.damontecres.wholphin.ui.components.TextButton + +@Composable +fun QuickConnectDialog( + onSubmit: (String) -> Unit, + onDismissRequest: () -> Unit, + elevation: Dp = 3.dp, +) { + var code by remember { mutableStateOf("") } + var showError by remember { mutableStateOf(false) } + + val isValidCode: (String) -> Boolean = { value -> + val trimmed = value.trim() + trimmed.length == 6 && trimmed.all { it.isDigit() } + } + + val onSubmitCode = { + if (isValidCode(code)) { + showError = false + onSubmit(code.trim()) + } else { + showError = true + } + } + + Dialog( + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + onDismissRequest = onDismissRequest, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .padding(16.dp) + .width(360.dp) + .background( + color = MaterialTheme.colorScheme.surfaceColorAtElevation(elevation), + shape = RoundedCornerShape(8.dp), + ), + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.quick_connect_code), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + + EditTextBox( + value = code, + onValueChange = { + code = it + showError = false + }, + keyboardOptions = + KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = + KeyboardActions( + onDone = { + onSubmitCode() + }, + ), + isInputValid = { value -> + !showError || isValidCode(value) + }, + modifier = Modifier.fillMaxWidth(), + ) + + if (showError) { + Text( + text = stringResource(R.string.quick_connect_code_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + + TextButton( + stringRes = R.string.submit, + onClick = onSubmitCode, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2dbad4f1..b8fdc1fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,6 +458,11 @@ Seerr integration Remove Seerr Server Seerr server added + Quick Connect + Authorize another device to log in to your account + Enter Quick Connect code + Code must be 6 digits + Device authorized successfully Password Username URL From 4161ea418c3e6156d46db97f9526303d484abe4d Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 11 Feb 2026 20:06:15 -0500 Subject: [PATCH 059/176] Fixes the skip segment button appearance behavior (#884) ## Description Fixes the segment (intro, credits, etc) skip button appearance behavior For the button shown on the video: if the button for a given segment is clicked, dismissed by the back button, or times out after 10 seconds, it will never appear again even if you rewind the video. However, the current non-ignored segment will always show a button on the playback controls though so it's possible to still use it. ### Related issues Fixes #875 ### Testing Tested a bunch of scenarios on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/Extensions.kt | 9 +++ .../wholphin/ui/playback/PlaybackPage.kt | 17 ++--- .../wholphin/ui/playback/PlaybackViewModel.kt | 73 +++++++++++++++---- 3 files changed, 73 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index 12854cd7..c997efd1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -391,6 +391,15 @@ fun CoroutineScope.launchIO( block: suspend CoroutineScope.() -> Unit, ): Job = launch(context = Dispatchers.IO + context, start = start, block = block) +/** + * Launches a coroutine with [Dispatchers.Default] plus the provided [CoroutineContext] defaulting to using [ExceptionHandler] + */ +fun CoroutineScope.launchDefault( + context: CoroutineContext = ExceptionHandler(), + start: CoroutineStart = CoroutineStart.DEFAULT, + block: suspend CoroutineScope.() -> Unit, +): Job = launch(context = Dispatchers.Default + context, start = start, block = block) + /** * Converts a UUID to the format used server-side (ie without hyphens). * 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 f8b80020..0313a8c5 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 @@ -86,7 +86,6 @@ import com.github.damontecres.wholphin.util.mpv.MpvPlayer 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 @@ -163,8 +162,7 @@ fun PlaybackPageContent( itemId = UUID.randomUUID(), ), ) - val currentSegment by viewModel.currentSegment.observeAsState(null) - var segmentCancelled by remember(currentSegment?.id) { mutableStateOf(false) } + val currentSegment by viewModel.currentSegment.collectAsState() val cues by viewModel.subtitleCues.observeAsState(listOf()) var showDebugInfo by remember { mutableStateOf(prefs.showDebugInfo) } @@ -302,10 +300,10 @@ fun PlaybackPageContent( } val showSegment = - !segmentCancelled && currentSegment != null && + currentSegment?.interacted == false && nextUp == null && !controllerViewState.controlsVisible && skipIndicatorDuration == 0L BackHandler(showSegment) { - segmentCancelled = true + viewModel.updateSegment(currentSegment?.segment?.id, true) } Box( @@ -400,7 +398,7 @@ fun PlaybackPageContent( onClickPlaylist = { viewModel.playItemInPlaylist(it) }, - currentSegment = currentSegment, + currentSegment = currentSegment?.segment, showClock = preferences.appPreferences.interfacePreferences.showClock, ) } @@ -462,13 +460,12 @@ fun PlaybackPageContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() delay(10.seconds) - segmentCancelled = true + viewModel.updateSegment(segment.segment.id, true) } TextButton( - stringRes = segment.type.skipStringRes, + stringRes = segment.segment.type.skipStringRes, onClick = { - segmentCancelled = true - player.seekTo(segment.endTicks.ticks.inWholeMilliseconds) + viewModel.updateSegment(segment.segment.id, false) }, modifier = Modifier.focusRequester(focusRequester), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 931ac551..7ed25311 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -49,6 +49,7 @@ import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.onMain @@ -57,7 +58,6 @@ import com.github.damontecres.wholphin.ui.seekForward import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString -import com.github.damontecres.wholphin.util.EqualityMutableLiveData import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.TrackActivityPlaybackListener @@ -164,7 +164,7 @@ class PlaybackViewModel val currentMediaInfo = MutableLiveData(CurrentMediaInfo.EMPTY) val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() - val currentSegment = EqualityMutableLiveData(null) + val currentSegment = MutableStateFlow(null) private val autoSkippedSegments = mutableSetOf() val subtitleCues = MutableLiveData>(listOf()) @@ -962,10 +962,10 @@ class PlaybackViewModel /** * Cancels listening for segments and clears current segment state */ - private suspend fun resetSegmentState() { + private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() - currentSegment.setValueOnMain(null) + currentSegment.value = null } /** @@ -988,15 +988,21 @@ class PlaybackViewModel it.type != MediaSegmentType.UNKNOWN && currentTicks >= it.startTicks && currentTicks < it.endTicks } if (currentSegment != null && - currentSegment.itemId == this@PlaybackViewModel.itemId && - autoSkippedSegments.add(currentSegment.id) + currentSegment.itemId == this@PlaybackViewModel.itemId ) { - Timber.d( - "Found media segment for %s: %s, %s", - currentSegment.itemId, - currentSegment.id, - currentSegment.type, - ) + if (currentSegment.id != + this@PlaybackViewModel + .currentSegment.value + ?.segment + ?.id + ) { + Timber.d( + "Found media segment for %s: %s, %s", + currentSegment.itemId, + currentSegment.id, + currentSegment.type, + ) + } val playlist = this@PlaybackViewModel.playlist.value if (currentSegment.type == MediaSegmentType.OUTRO && @@ -1021,13 +1027,21 @@ class PlaybackViewModel withContext(Dispatchers.Main) { when (behavior) { SkipSegmentBehavior.AUTO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = null - player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) + if (autoSkippedSegments.add(currentSegment.id)) { + onMain { player.seekTo(currentSegment.endTicks.ticks.inWholeMilliseconds + 1) } + } + this@PlaybackViewModel.currentSegment.update { + MediaSegmentState(currentSegment, true) + } } SkipSegmentBehavior.ASK_TO_SKIP -> { - this@PlaybackViewModel.currentSegment.value = - currentSegment + this@PlaybackViewModel.currentSegment.update { + MediaSegmentState( + currentSegment, + autoSkippedSegments.contains(currentSegment.id), + ) + } } else -> { @@ -1046,6 +1060,28 @@ class PlaybackViewModel } } + fun updateSegment( + segmentId: UUID?, + dismissed: Boolean, + ) { + viewModelScope.launchDefault { + val segment = currentSegment.value?.segment + if (segment != null && segment.id == segmentId) { + autoSkippedSegments.add(segment.id) + if (dismissed) { + currentSegment.update { + it?.copy(interacted = true) + } + } else { + currentSegment.update { + null + } + onMain { player.seekTo(segment.endTicks.ticks.inWholeMilliseconds + 1) } + } + } + } + } + private fun listenForTranscodeReason(): Job = viewModelScope.launchIO { currentPlayback.collectLatest { @@ -1379,3 +1415,8 @@ data class PlayerState( val player: Player, val backend: PlayerBackend, ) + +data class MediaSegmentState( + val segment: MediaSegmentDto, + val interacted: Boolean, +) From 11fd780b311a2b32a4036a82ca9c91bb7c02bad8 Mon Sep 17 00:00:00 2001 From: Justin Caveda Date: Thu, 12 Feb 2026 17:27:59 -0600 Subject: [PATCH 060/176] Fix suggestions cache miss handling for missing files (#880) ## Description Fix a bug where movie suggestions would get stuck loading when the on-disk cache file doesn't exist. ### Related issues Fixes #876 ### Testing Ran unit tests locally. ## Screenshots N/A ## AI or LLM usage Used AI to add testing for this scenario to unit test --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/services/SuggestionService.kt | 35 ++++++++++------- .../wholphin/services/SuggestionsCache.kt | 17 +++++--- .../ui/components/RecommendedMovie.kt | 3 ++ .../ui/components/RecommendedTvShow.kt | 3 ++ .../services/SuggestionServiceTest.kt | 39 +++++++++++++++++++ 5 files changed, 76 insertions(+), 21 deletions(-) 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 index db6cef2c..0fe32179 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionService.kt @@ -6,6 +6,7 @@ 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.CancellationException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest @@ -49,21 +50,8 @@ class SuggestionService .asFlow() .flatMapLatest { user -> val userId = user?.id ?: return@flatMapLatest flowOf(SuggestionsResource.Empty) - val cachedIds = cache.get(userId, parentId, itemKind)?.ids.orEmpty() - 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 { + val cachedSuggestions = cache.get(userId, parentId, itemKind) + if (cachedSuggestions == null) { workManager .getWorkInfosForUniqueWorkFlow(SuggestionsWorker.WORK_NAME) .map { workInfos -> @@ -73,6 +61,23 @@ class SuggestionService } if (isActive) SuggestionsResource.Loading else SuggestionsResource.Empty } + } else if (cachedSuggestions.ids.isEmpty()) { + flowOf(SuggestionsResource.Empty) + } else { + flow { + try { + emit( + SuggestionsResource.Success( + fetchItemsByIds(cachedSuggestions.ids, itemKind), + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.e(e, "Failed to fetch items") + emit(SuggestionsResource.Empty) + } + } } } } 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 index 5542ea51..0921a1be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsCache.kt @@ -60,15 +60,20 @@ class SuggestionsCache ): CachedSuggestions? { val key = cacheKey(userId, libraryId, itemKind) return memoryCache.getOrPut(key) { - try { - mutex.withLock { - File(cacheDir, "$key.json").inputStream().use { + mutex.withLock { + try { + val cacheFile = File(cacheDir, "$key.json") + if (!cacheFile.exists()) { + return@withLock null + } + + cacheFile.inputStream().use { json.decodeFromStream(it) } + } catch (ex: Exception) { + Timber.e(ex, "Exception reading from disk cache") + null } - } catch (ex: Exception) { - Timber.e(ex, "Exception reading from disk cache") - null } } } 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 a1a5be80..403ff5fa 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 @@ -32,6 +32,7 @@ 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.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -207,6 +208,8 @@ class RecommendedMovieViewModel } update(R.string.suggestions, state) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Failed to fetch suggestions") update( 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 00430a2c..3258930c 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 @@ -33,6 +33,7 @@ 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.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -254,6 +255,8 @@ class RecommendedTvShowViewModel } update(R.string.suggestions, state) } + } catch (ex: CancellationException) { + throw ex } catch (ex: Exception) { Timber.e(ex, "Failed to fetch suggestions") update( 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 index e1663543..f8fc8445 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionServiceTest.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.util.GetItemsRequestHandler 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.flow.first @@ -150,6 +151,44 @@ class SuggestionServiceTest { assertEquals(SuggestionsResource.Empty, result) } + @Test + fun getSuggestionsFlow_returnsEmpty_whenCachedIdsEmpty_evenIfWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns CachedSuggestions(emptyList()) + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Empty, result) + verify(exactly = 0) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + + @Test + fun getSuggestionsFlow_returnsLoading_whenCacheMissing_andWorkIsEnqueued() = + runTest { + val userId = UUID.randomUUID() + val parentId = UUID.randomUUID() + val currentUser = MutableLiveData(mockUser(userId)) + + every { mockServerRepository.currentUser } returns currentUser + coEvery { mockCache.get(userId, parentId, BaseItemKind.MOVIE) } returns null + every { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } returns + flowOf(listOf(mockWorkInfo(WorkInfo.State.ENQUEUED))) + + val service = createService() + val result = service.getSuggestionsFlow(parentId, BaseItemKind.MOVIE).first() + + assertEquals(SuggestionsResource.Loading, result) + verify(exactly = 1) { mockWorkManager.getWorkInfosForUniqueWorkFlow(any()) } + } + @Test fun passes_correct_arguments_to_cache() = runTest { From 3f0dedd71efe933604a57a7f1847eca8a9e3e525 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:21:16 -0500 Subject: [PATCH 061/176] Fix error pages overlapping with nav drawer (#887) ## Description As of #842, if an error occurred on some pages that display a generic error message, it would be shown under the nav drawer making it difficult to read. This PR fixes that by passing the `modifier` into the `ErrorMessage` composables ensuring it has proper offset & padding. Also, for consistency, passes it the `LoadingPage`s. ### Related issues Caused by #842 ### Testing Threw an exception during home page loading to observe the issue and fix on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/CollectionFolderGrid.kt | 2 +- .../com/github/damontecres/wholphin/ui/components/ItemGrid.kt | 4 ++-- .../damontecres/wholphin/ui/components/RecommendedContent.kt | 4 ++-- .../wholphin/ui/detail/discover/DiscoverMovieDetails.kt | 4 ++-- .../wholphin/ui/detail/discover/DiscoverSeriesDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/movie/MovieDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/series/SeriesDetails.kt | 4 ++-- .../damontecres/wholphin/ui/detail/series/SeriesOverview.kt | 4 ++-- .../java/com/github/damontecres/wholphin/ui/main/HomePage.kt | 4 ++-- .../github/damontecres/wholphin/ui/slideshow/SlideshowPage.kt | 4 ++-- 11 files changed, 21 insertions(+), 21 deletions(-) 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 6d20211f..c44f3b3c 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 @@ -531,7 +531,7 @@ fun CollectionFolderGrid( DataLoadingState.Loading, DataLoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } is DataLoadingState.Error, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt index 1e274de6..38591a67 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/ItemGrid.kt @@ -95,13 +95,13 @@ fun ItemGrid( val items by viewModel.items.observeAsState(listOf()) when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 6c577c9b..174f4665 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -139,13 +139,13 @@ fun RecommendedContent( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 7df86615..0c7d08b9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -113,13 +113,13 @@ fun DiscoverMovieDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 64748bd0..3c036534 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -105,13 +105,13 @@ fun DiscoverSeriesDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index bac9f51a..63d5ee5d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -102,13 +102,13 @@ fun EpisodeDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 23ed08c3..2a40a399 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -130,13 +130,13 @@ fun MovieDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 55335057..f0c74be3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -120,13 +120,13 @@ fun SeriesDetails( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index a8a9d83f..8fba8ed6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -148,13 +148,13 @@ fun SeriesOverview( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index e41acd1b..28937e55 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -103,13 +103,13 @@ fun HomePage( when (val state = loading) { is LoadingState.Error -> { - ErrorMessage(state) + ErrorMessage(state, modifier) } LoadingState.Loading, LoadingState.Pending, -> { - LoadingPage() + LoadingPage(modifier) } LoadingState.Success -> { 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 index a513715d..1c121ef6 100644 --- 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 @@ -314,11 +314,11 @@ fun SlideshowPage( ) { when (loadingState) { ImageLoadingState.Error -> { - ErrorMessage("Error loading image", null) + ErrorMessage("Error loading image", null, modifier) } ImageLoadingState.Loading -> { - LoadingPage() + LoadingPage(modifier) } is ImageLoadingState.Success -> { From b7679b3aa1f8a81e0f1c560ce4e2a9b66c1e3f37 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Thu, 12 Feb 2026 19:26:19 -0500 Subject: [PATCH 062/176] Release v0.4.3 From fcba1c744405cc3686afa8c2ad73174ec0c210c7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:54:51 -0500 Subject: [PATCH 063/176] Customize home page (#803) ## Description This PR adds the ability to customize the home page in-app ### Features - Add, remove, & reorder rows - Persist the configuration locally - Save the configuration to the server, allowing to pull down on other devices - Adjust view options for rows such as card height, image type, preferring series images, or aspect ratio (similar to libraries) - Pull down the web client's home rows (no plugins are supported yet!) - Preview of the home page which is usable & updated as changes are made ### Row options These are row types that can be added in-app via the UI: - Continue watching - Next up - Combined continue waiting & next up - Recently added in a library - Recently released in a library - Genres in a library - Suggestions for a library (movie & TV show libraries only) - Favorite movies, tv shows, episodes, etc Additionally, there are more row types that don't have a UI to add them (yet): - Simple query to get items from a parent ID such as a collection or playlist - Complex query to get arbitrary items via the `/Items` API endpoint ### Dev notes Settings are loaded in order: 1. Locally saved 2. Remote saved 3. Fallback to default similar to Wholphin's original home rows The remote saved settings are stored via the display preferences API. I know some server admins would prefer to push a default setup to their clients. This PR does not have that ability, but it does define a straightforward API for defining the settings. Something like the potential server plugin work started in #625 could be slimmed down to expose a URL to be added in the load order. I'm also investigating integration with popular home page plugins to allow for further customization, but will take more time. ### Related issues Closes #399 Closes #361 Closes #282 Related to #340 --- .../wholphin/data/model/BaseItem.kt | 1 + .../wholphin/data/model/HomeRowConfig.kt | 221 ++++ .../wholphin/preferences/AppPreference.kt | 12 +- .../wholphin/services/BackdropService.kt | 8 +- .../wholphin/services/HomeSettingsService.kt | 951 ++++++++++++++++++ .../wholphin/services/LatestNextUpService.kt | 23 +- .../services/SeerrServerRepository.kt | 82 +- .../wholphin/services/SuggestionsWorker.kt | 14 +- .../services/tvprovider/TvProviderWorker.kt | 8 +- .../damontecres/wholphin/ui/UiConstants.kt | 6 +- .../wholphin/ui/cards/BannerCard.kt | 104 +- .../wholphin/ui/cards/GenreCard.kt | 31 +- .../wholphin/ui/cards/SeasonCard.kt | 36 +- .../ui/components/FocusableItemRow.kt | 78 ++ .../wholphin/ui/components/GenreCardGrid.kt | 107 +- .../damontecres/wholphin/ui/main/HomePage.kt | 246 +++-- .../wholphin/ui/main/HomeViewModel.kt | 227 +++-- .../main/settings/HomeLibraryRowTypeList.kt | 81 ++ .../ui/main/settings/HomeRowPresets.kt | 206 ++++ .../ui/main/settings/HomeRowSettings.kt | 307 ++++++ .../ui/main/settings/HomeSettingsAddRow.kt | 102 ++ .../main/settings/HomeSettingsDestination.kt | 38 + .../main/settings/HomeSettingsFavoriteList.kt | 64 ++ .../ui/main/settings/HomeSettingsGlobal.kt | 184 ++++ .../ui/main/settings/HomeSettingsListItem.kt | 59 ++ .../ui/main/settings/HomeSettingsPage.kt | 290 ++++++ .../ui/main/settings/HomeSettingsRowList.kt | 269 +++++ .../ui/main/settings/HomeSettingsViewModel.kt | 670 ++++++++++++ .../wholphin/ui/nav/Destination.kt | 3 + .../wholphin/ui/nav/DestinationContent.kt | 5 + .../damontecres/wholphin/util/Constants.kt | 8 + .../damontecres/wholphin/util/LoadingState.kt | 2 + app/src/main/res/values/fa_strings.xml | 2 + app/src/main/res/values/strings.xml | 23 + .../wholphin/test/TestHomeRowSamples.kt | 150 +++ 35 files changed, 4297 insertions(+), 321 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt 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 2d8c7baa..3030b7d0 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 @@ -32,6 +32,7 @@ import kotlin.time.Duration data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, + val imageUrlOverride: String? = null, ) : CardGridItem { val id get() = data.id diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt new file mode 100644 index 00000000..7d521ce4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -0,0 +1,221 @@ +@file:UseSerializers(UUIDSerializer::class) + +package com.github.damontecres.wholphin.data.model + +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import java.util.UUID + +@Serializable +sealed interface HomeRowConfig { + val viewOptions: HomeRowViewOptions + + fun updateViewOptions(viewOptions: HomeRowViewOptions): HomeRowConfig + + /** + * Continue watching media that the user has started but not finished + */ + @Serializable + @SerialName("ContinueWatching") + data class ContinueWatching( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatching = this.copy(viewOptions = viewOptions) + } + + /** + * Next up row for next episodes in a series the user has started + */ + @Serializable + @SerialName("NextUp") + data class NextUp( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): NextUp = this.copy(viewOptions = viewOptions) + } + + /** + * Combined [ContinueWatching] and [NextUp] + */ + @Serializable + @SerialName("ContinueWatchingCombined") + data class ContinueWatchingCombined( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ContinueWatchingCombined = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently added to a library + */ + @Serializable + @SerialName("RecentlyAdded") + data class RecentlyAdded( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyAdded = this.copy(viewOptions = viewOptions) + } + + /** + * Media recently released (premiere date) in a library + */ + @Serializable + @SerialName("RecentlyReleased") + data class RecentlyReleased( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): RecentlyReleased = this.copy(viewOptions = viewOptions) + } + + /** + * Row of a genres in a library + */ + @Serializable + @SerialName("Genres") + data class Genres( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) + } + + /** + * Favorites for a specific type + */ + @Serializable + @SerialName("Favorite") + data class Favorite( + val kind: BaseItemKind, + override val viewOptions: HomeRowViewOptions = + if (kind == BaseItemKind.EPISODE) { + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } else { + HomeRowViewOptions() + }, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Favorite = this.copy(viewOptions = viewOptions) + } + + /** + * + */ + @Serializable + @SerialName("Recordings") + data class Recordings( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Recordings = this.copy(viewOptions = viewOptions) + } + + /** + * + */ + @Serializable + @SerialName("TvPrograms") + data class TvPrograms( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID + */ + @Serializable + @SerialName("Suggestions") + data class Suggestions( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Suggestions = this.copy(viewOptions = viewOptions) + } + + /** + * Fetch by parent ID such as a library, collection, or playlist with optional simple sorting + */ + @Serializable + @SerialName("ByParent") + data class ByParent( + val parentId: UUID, + val recursive: Boolean, + val sort: SortAndDirection? = null, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): ByParent = this.copy(viewOptions = viewOptions) + } + + /** + * An arbitrary [GetItemsRequest] allowing to query for anything + */ + @Serializable + @SerialName("GetItems") + data class GetItems( + val name: String, + val getItems: GetItemsRequest, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): GetItems = this.copy(viewOptions = viewOptions) + } +} + +/** + * Root class for home page settings + * + * Contains the list of rows and a version + */ +@Serializable +@SerialName("HomePageSettings") +data class HomePageSettings( + val rows: List, + val version: Int, +) { + companion object { + val EMPTY = HomePageSettings(listOf(), SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + } +} + +/** + * This is the max version supported by this version of the app + */ +const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 + +/** + * View options for displaying a row + * + * Allows for changing things like height or aspect ratio + */ +@Serializable +data class HomeRowViewOptions( + val heightDp: Int = Cards.HEIGHT_2X3_DP, + val spacing: Int = 16, + val contentScale: PrefContentScale = PrefContentScale.FIT, + val aspectRatio: AspectRatio = AspectRatio.TALL, + val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, + val showTitles: Boolean = false, + val useSeries: Boolean = true, + val episodeContentScale: PrefContentScale = PrefContentScale.FIT, + val episodeAspectRatio: AspectRatio = AspectRatio.TALL, + val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, +) { + companion object { + val genreDefault = + HomeRowViewOptions( + heightDp = Cards.HEIGHT_EPISODE, + aspectRatio = AspectRatio.WIDE, + ) + } +} 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 4c17ac07..7eaa2ffe 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 @@ -656,6 +656,12 @@ sealed interface AppPreference { setter = { prefs, _ -> prefs }, ) + val CustomizeHome = + AppDestinationPreference( + title = R.string.customize_home, + destination = Destination.HomeSettings, + ) + val SendCrashReports = AppSwitchPreference( title = R.string.send_crash_reports, @@ -1000,10 +1006,6 @@ val basicPreferences = preferences = listOf( AppPreference.SignInAuto, - AppPreference.HomePageItems, - AppPreference.CombineContinueNext, - AppPreference.RewatchNextUp, - AppPreference.MaxDaysNextUp, AppPreference.PlayThemeMusic, AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, @@ -1034,6 +1036,7 @@ val basicPreferences = preferences = listOf( AppPreference.RequireProfilePin, + AppPreference.CustomizeHome, AppPreference.UserPinnedNavDrawerItems, ), ), @@ -1099,6 +1102,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, AppPreference.ControllerTimeout, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index a64fc9d9..e082c5fa 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import timber.log.Timber import javax.inject.Inject @@ -47,7 +48,12 @@ class BackdropService suspend fun submit(item: BaseItem) = withContext(Dispatchers.IO) { - val imageUrl = imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + val imageUrl = + if (item.type == BaseItemKind.GENRE) { + item.imageUrlOverride + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + } submit(item.id.toString(), imageUrl) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt new file mode 100644 index 00000000..a868692a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -0,0 +1,951 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration +import com.github.damontecres.wholphin.preferences.HomePagePreferences +import com.github.damontecres.wholphin.ui.DefaultItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.toServerString +import com.github.damontecres.wholphin.util.GetGenresRequestHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success +import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.displayPreferencesApi +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userApi +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.UserDto +import org.jellyfin.sdk.model.api.request.GetGenresRequest +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest +import org.jellyfin.sdk.model.api.request.GetPersonsRequest +import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest +import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HomeSettingsService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val navDrawerItemRepository: NavDrawerItemRepository, + private val latestNextUpService: LatestNextUpService, + private val imageUrlService: ImageUrlService, + private val suggestionService: SuggestionService, + ) { + @OptIn(ExperimentalSerializationApi::class) + val jsonParser = + Json { + isLenient = true + ignoreUnknownKeys = true + allowTrailingComma = true + } + + val currentSettings = MutableStateFlow(HomePageResolvedSettings.EMPTY) + + /** + * Saves a [HomePageSettings] to the server for the user under the display preference ID + * + * @see loadFromServer + */ + suspend fun saveToServer( + userId: UUID, + settings: HomePageSettings, + displayPreferencesId: String = DISPLAY_PREF_ID, + ) { + val current = getDisplayPreferences(userId, DISPLAY_PREF_ID) + val customPrefs = + current.customPrefs.toMutableMap().apply { + put(CUSTOM_PREF_ID, jsonParser.encodeToString(settings)) + } + api.displayPreferencesApi.updateDisplayPreferences( + displayPreferencesId = displayPreferencesId, + userId = userId, + client = context.getString(R.string.app_name), + data = current.copy(customPrefs = customPrefs), + ) + } + + /** + * Reads a [HomePageSettings] from the server for the user and display preference ID + * + * Returns null if there is none saved + * + * @see saveToServer + */ + suspend fun loadFromServer( + userId: UUID, + displayPreferencesId: String = DISPLAY_PREF_ID, + ): HomePageSettings? { + val current = getDisplayPreferences(userId, displayPreferencesId) + return current.customPrefs[CUSTOM_PREF_ID]?.let { + val jsonElement = jsonParser.parseToJsonElement(it) + decode(jsonElement) + } + } + + private suspend fun getDisplayPreferences( + userId: UUID, + displayPreferencesId: String, + ) = api.displayPreferencesApi + .getDisplayPreferences( + userId = userId, + displayPreferencesId = displayPreferencesId, + client = context.getString(R.string.app_name), + ).content + + /** + * Computes the filename for locally saved [HomePageSettings] + */ + private fun filename(userId: UUID) = "${CUSTOM_PREF_ID}_${userId.toServerString()}.json" + + /** + * Save the [HomePageSettings] for the user locally on the device + * + * @see loadFromLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun saveToLocal( + userId: UUID, + settings: HomePageSettings, + ) { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + dir.mkdirs() + File(dir, filename(userId)).outputStream().use { + jsonParser.encodeToStream(settings, it) + } + } + + /** + * Reads [HomePageSettings] for the user if it exists + * + * @see saveToLocal + */ + @OptIn(ExperimentalSerializationApi::class) + suspend fun loadFromLocal(userId: UUID): HomePageSettings? { + val dir = File(context.filesDir, CUSTOM_PREF_ID) + val file = File(dir, filename(userId)) + return if (file.exists()) { + val fileContents = file.readText() + val jsonElement = jsonParser.parseToJsonElement(fileContents) + decode(jsonElement) + } else { + null + } + } + + /** + * Decodes [HomePageSettings] from a [JsonElement] skipping any unknown/unparsable rows + * + * This is public only for testing + */ + fun decode(element: JsonElement): HomePageSettings { + val version = element.jsonObject["version"]?.jsonPrimitive?.intOrNull + if (version == null || version > SUPPORTED_HOME_PAGE_SETTINGS_VERSION) { + throw UnsupportedHomeSettingsVersionException(version) + } + val rowsElement = element.jsonObject["rows"]?.jsonArray + val rows = + rowsElement + ?.mapNotNull { row -> + try { + jsonParser.decodeFromJsonElement(row) + } catch (ex: Exception) { + Timber.w(ex, "Unknown row %s", row) + // TODO maybe use placeholder instead of null? + null + } + }.orEmpty() + return HomePageSettings(rows, version) + } + + /** + * Loads [HomePageSettings] into [currentSettings] + * + * First checks locally, then on the server, and finally creates a default if needed + * + * Does not persist either the server nor default + */ + suspend fun loadCurrentSettings(userId: UUID) { + Timber.v("Getting setting for %s", userId) + // User local then server/remote otherwise create a default + val settings = + try { + val local = loadFromLocal(userId) + Timber.v("Found local? %s", local != null) + local + } catch (ex: Exception) { + Timber.w(ex, "Error loading local settings") + // TODO show toast? + null + } ?: try { + val remote = loadFromServer(userId) + Timber.v("Found remote? %s", remote != null) + remote + } catch (ex: Exception) { + Timber.w(ex, "Error loading remote settings") + null + } + val resolvedSettings = + if (settings != null) { + Timber.v("Found settings") + // Resolve + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + HomePageResolvedSettings(resolvedRows) + } else { + createDefault() + } + + currentSettings.update { resolvedSettings } + } + + suspend fun updateCurrent(settings: HomePageSettings) { + val resolvedRows = + settings.rows.mapIndexed { index, config -> + resolve(index, config) + } + val resolvedSettings = HomePageResolvedSettings(resolvedRows) + currentSettings.update { resolvedSettings } + } + + /** + * Create a default [HomePageResolvedSettings] using the available libraries + */ + suspend fun createDefault(): HomePageResolvedSettings { + Timber.v("Creating default settings") + val navDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } + val prefs = + userPreferencesService.getCurrent().appPreferences.homePagePreferences + val includedIds = + navDrawerItemRepository + .getFilteredNavDrawerItems(navDrawerItems) + .filter { it is ServerNavDrawerItem } + .mapIndexed { index, it -> + val parentId = (it as ServerNavDrawerItem).itemId + val name = libraries.firstOrNull { it.itemId == parentId }?.name + val title = + name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + HomeRowConfigDisplay( + id = index, + title = title, + config = HomeRowConfig.RecentlyAdded(parentId), + ) + } + val continueWatchingRows = + if (prefs.combineContinueNext) { // TODO + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.combine_continue_next), + config = HomeRowConfig.ContinueWatchingCombined(), + ), + ) + } else { + listOf( + HomeRowConfigDisplay( + id = includedIds.size + 1, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ), + HomeRowConfigDisplay( + id = includedIds.size + 2, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ), + ) + } + val rowConfig = continueWatchingRows + includedIds + return HomePageResolvedSettings(rowConfig) + } + + suspend fun parseFromWebConfig(userId: UUID): HomePageResolvedSettings? { + val customPrefs = + api.displayPreferencesApi + .getDisplayPreferences( + displayPreferencesId = "usersettings", + userId = userId, + client = "emby", + ).content.customPrefs + val userDto by api.userApi.getUserById(userId) + val config = userDto.configuration ?: DefaultUserConfiguration + val libraries = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + .filter { + it.collectionType in supportedHomeCollectionTypes && + it.id !in config.latestItemsExcludes + } + + return if (customPrefs.isNotEmpty()) { + var id = 0 + val rowConfigs = + (0..9) + .mapNotNull { idx -> + val sectionType = + HomeSectionType.fromString(customPrefs["homesection$idx"]?.lowercase()) + Timber.v( + "sectionType=$sectionType, %s", + customPrefs["homesection$idx"]?.lowercase(), + ) + val config = + when (sectionType) { + HomeSectionType.ACTIVE_RECORDINGS -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.active_recordings), + config = HomeRowConfig.Recordings(), + ) + } + + HomeSectionType.RESUME -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.continue_watching), + config = HomeRowConfig.ContinueWatching(), + ) + } + + HomeSectionType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.next_up), + config = HomeRowConfig.NextUp(), + ) + } + + HomeSectionType.LIVE_TV -> { + if (userDto.policy?.enableLiveTvAccess == true) { + HomeRowConfigDisplay( + id = id++, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + null + } + } + + HomeSectionType.LATEST_MEDIA -> { + // Handled below + null + } + + // Unsupported + HomeSectionType.RESUME_AUDIO, + HomeSectionType.RESUME_BOOK, + -> { + null + } + + HomeSectionType.SMALL_LIBRARY_TILES, + HomeSectionType.LIBRARY_BUTTONS, + HomeSectionType.NONE, + null, + -> { + null + } + } + if (sectionType == HomeSectionType.LATEST_MEDIA) { + libraries.map { + HomeRowConfigDisplay( + id = id++, + title = + context.getString( + R.string.recently_added_in, + it.name ?: "", + ), + config = HomeRowConfig.RecentlyAdded(it.id), + ) + } + } else if (config != null) { + listOf(config) + } else { + null + } + }.flatten() + HomePageResolvedSettings(rowConfigs) + } else { + null + } + } + + /** + * Converts a [HomeRowConfig] into [HomeRowConfigDisplay] for UI purposes + */ + suspend fun resolve( + id: Int, + config: HomeRowConfig, + ): HomeRowConfigDisplay = + when (config) { + is HomeRowConfig.ByParent -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + name, + config, + ) + } + + is HomeRowConfig.ContinueWatching -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.continue_watching), + config, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.combine_continue_next), + config, + ) + } + + is HomeRowConfig.Genres -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.genres_in, name), + config, + ) + } + + is HomeRowConfig.GetItems -> { + HomeRowConfigDisplay(id, config.name, config) + } + + is HomeRowConfig.NextUp -> { + HomeRowConfigDisplay( + id, + context.getString(R.string.next_up), + config, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_added_in, name), + config, + ) + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.recently_released_in, name), + config, + ) + } + + is HomeRowConfig.Favorite -> { + val name = context.getString(R.string.favorites) // TODO "Favorite " + HomeRowConfigDisplay(id, name, config) + } + + is HomeRowConfig.Recordings -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.active_recordings), + config, + ) + } + + is HomeRowConfig.TvPrograms -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.live_tv), + config, + ) + } + + is HomeRowConfig.Suggestions -> { + val name = + api.userLibraryApi + .getItem(itemId = config.parentId) + .content.name ?: "" + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.suggestions_for, name), + config, + ) + } + } + + /** + * Fetch the data from the server for a given [HomeRowConfig] + */ + suspend fun fetchDataForRow( + row: HomeRowConfig, + scope: CoroutineScope, + prefs: HomePagePreferences, + userDto: UserDto, + libraries: List, + limit: Int = prefs.maxItemsPerRow, + ): HomeRowLoadingState = + when (row) { + is HomeRowConfig.ContinueWatching -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = resume, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.NextUp -> { + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.next_up), + items = nextUp, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.ContinueWatchingCombined -> { + val resume = + latestNextUpService.getResume( + userDto.id, + limit, + true, + row.viewOptions.useSeries, + ) + val nextUp = + latestNextUpService.getNextUp( + userDto.id, + limit, + prefs.enableRewatchingNextUp, + false, + prefs.maxDaysNextUp, + row.viewOptions.useSeries, + ) + + Success( + title = context.getString(R.string.continue_watching), + items = + latestNextUpService.buildCombined( + resume, + nextUp, + ), + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.Genres -> { + val request = + GetGenresRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + ) + val items = + GetGenresRequestHandler + .execute(api, request) + .content.items + val genreIds = items.map { it.id } + val genreImages = + getGenreImageMap( + api = api, + scope = scope, + imageUrlService = imageUrlService, + genres = genreIds, + parentId = row.parentId, + includeItemTypes = null, + cardWidthPx = null, + ) + val genres = + items.map { + BaseItem(it, false, genreImages[it.id]) + } + + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { context.getString(R.string.genres_in, it) } + ?: context.getString(R.string.genres) + + Success( + title, + genres, + viewOptions = row.viewOptions, + ) + } + + is HomeRowConfig.RecentlyAdded -> { + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + val request = + GetLatestMediaRequest( + fields = SlimItemFields, + imageTypeLimit = 1, + parentId = row.parentId, + groupItems = true, + limit = limit, + isPlayed = null, // Server will handle user's preference + ) + val latest = + api.userLibraryApi + .getLatestMedia(request) + .content + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + latest + } + + is HomeRowConfig.RecentlyReleased -> { + val name = + libraries + .firstOrNull { it.itemId == row.parentId } + ?.name + val title = + name?.let { + context.getString(R.string.recently_released_in, it) + } ?: context.getString(R.string.recently_released) + val request = + GetItemsRequest( + parentId = row.parentId, + limit = limit, + sortBy = listOf(ItemSortBy.PREMIERE_DATE), + sortOrder = listOf(SortOrder.DESCENDING), + fields = DefaultItemFields, + recursive = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem.Companion.from(it, api, row.viewOptions.useSeries) } + .let { + Success( + title, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.ByParent -> { + val request = + GetItemsRequest( + userId = userDto.id, + parentId = row.parentId, + recursive = row.recursive, + sortBy = row.sort?.let { listOf(it.sort) }, + sortOrder = row.sort?.let { listOf(it.direction) }, + limit = limit, + fields = DefaultItemFields, + ) + val name = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content.name + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + name ?: context.getString(R.string.collection), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.GetItems -> { + val request = + row.getItems.let { + if (it.limit == null) { + it.copy( + userId = userDto.id, + limit = limit, + ) + } else { + it.copy( + userId = userDto.id, + ) + } + } + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + row.name, + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Favorite -> { + if (row.kind == BaseItemKind.PERSON) { + val request = + GetPersonsRequest( + userId = userDto.id, + limit = limit, + fields = DefaultItemFields, + isFavorite = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + ) + GetPersonsHandler + .execute(api, request) + .content.items + .map { BaseItem(it, true) } + .let { + Success( + context.getString(R.string.favorites), // TODO + it, + row.viewOptions, + ) + } + } else { + val request = + GetItemsRequest( + userId = userDto.id, + recursive = true, + limit = limit, + fields = DefaultItemFields, + includeItemTypes = listOf(row.kind), + isFavorite = true, + ) + GetItemsRequestHandler + .execute(api, request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.favorites), // TODO + it, + row.viewOptions, + ) + } + } + } + + is HomeRowConfig.Recordings -> { + val request = + GetRecordingsRequest( + userId = userDto.id, + isInProgress = true, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + ) + api.liveTvApi + .getRecordings(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.active_recordings), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.TvPrograms -> { + val request = + GetRecommendedProgramsRequest( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + enableUserData = true, + ) + api.liveTvApi + .getRecommendedPrograms(request) + .content.items + .map { BaseItem(it, row.viewOptions.useSeries) } + .let { + Success( + context.getString(R.string.live_tv), + it, + row.viewOptions, + ) + } + } + + is HomeRowConfig.Suggestions -> { + val library = + api.userLibraryApi + .getItem(itemId = row.parentId) + .content + val title = context.getString(R.string.suggestions_for, library.name ?: "") + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + val suggestions = + itemKind?.let { + suggestionService + .getSuggestionsFlow(row.parentId, itemKind) + .firstOrNull() + } + if (suggestions != null && suggestions is SuggestionsResource.Success) { + Success( + title, + suggestions.items, + row.viewOptions, + ) + } else if (suggestions is SuggestionsResource.Empty) { + Success( + title, + listOf(), + row.viewOptions, + ) + } else { + HomeRowLoadingState.Error( + title, + message = "Unsupported type ${library.collectionType}", + ) + } + } + } + + companion object { + const val DISPLAY_PREF_ID = "default" + const val CUSTOM_PREF_ID = "home_settings" + } + } + +/** + * A [HomeRowConfig] with a resolved ID and title so it is usable in the UI + */ +data class HomeRowConfigDisplay( + val id: Int, + val title: String, + val config: HomeRowConfig, +) + +/** + * List of resolved [HomeRowConfig]s as [HomeRowConfigDisplay]s + * + * @see HomePageSettings + */ +data class HomePageResolvedSettings( + val rows: List, +) { + companion object { + val EMPTY = HomePageResolvedSettings(listOf()) + } +} + +// https://github.com/jellyfin/jellyfin/blob/v10.11.6/src/Jellyfin.Database/Jellyfin.Database.Implementations/Enums/HomeSectionType.cs +enum class HomeSectionType( + val serialName: String, +) { + NONE("none"), + SMALL_LIBRARY_TILES("smalllibrarytitles"), + LIBRARY_BUTTONS("librarybuttons"), + ACTIVE_RECORDINGS("activerecordings"), + RESUME("resume"), + RESUME_AUDIO("resumeaudio"), + LATEST_MEDIA("latestmedia"), + NEXT_UP("nextup"), + LIVE_TV("livetv"), + RESUME_BOOK("resumebook"), + ; + + companion object { + fun fromString(homeKey: String?) = homeKey?.let { entries.firstOrNull { it.serialName == homeKey } } + } +} + +class UnsupportedHomeSettingsVersionException( + val unsupportedVersion: Int?, + val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, +) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 6ac016a3..9342f63a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -4,8 +4,6 @@ import android.content.Context import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.SlimItemFields -import com.github.damontecres.wholphin.ui.main.LatestData -import com.github.damontecres.wholphin.ui.main.supportedLatestCollectionTypes import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.supportItemKinds import dagger.hilt.android.qualifiers.ApplicationContext @@ -21,6 +19,7 @@ import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest @@ -44,6 +43,7 @@ class LatestNextUpService userId: UUID, limit: Int, includeEpisodes: Boolean, + useSeriesForPrimary: Boolean = true, ): List { val request = GetResumeItemsRequest( @@ -66,7 +66,7 @@ class LatestNextUpService .getResumeItems(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return items } @@ -76,6 +76,7 @@ class LatestNextUpService enableRewatching: Boolean, enableResumable: Boolean, maxDays: Int, + useSeriesForPrimary: Boolean = true, ): List { val nextUpDateCutoff = maxDays.takeIf { it > 0 }?.let { LocalDateTime.now().minusDays(it.toLong()) } @@ -96,7 +97,7 @@ class LatestNextUpService .getNextUp(request) .content .items - .map { BaseItem.from(it, api, true) } + .map { BaseItem.from(it, api, useSeriesForPrimary) } return nextUp } @@ -192,3 +193,17 @@ class LatestNextUpService return@withContext result } } + +val supportedLatestCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.HOMEVIDEOS, + // Exclude Live TV because a recording folder view will be used instead + null, // Recordings & mixed collection types + ) + +data class LatestData( + val title: String, + val request: GetLatestMediaRequest, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index f06a7529..c6da831f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -226,6 +226,7 @@ class UserSwitchListener private val seerrServerRepository: SeerrServerRepository, private val seerrServerDao: SeerrServerDao, private val seerrApi: SeerrApi, + private val homeSettingsService: HomeSettingsService, ) { init { context as AppCompatActivity @@ -233,41 +234,58 @@ class UserSwitchListener serverRepository.currentUser.asFlow().collect { user -> Timber.d("New user") seerrServerRepository.clear() + homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .firstOrNull() - ?.let { seerrUser -> - val server = seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - try { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .firstOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + try { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.clear() + return@let + } + } else { + try { + seerrApi.api.usersApi.authMeGet() + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.clear() + return@let + } } - } else { - try { - seerrApi.api.usersApi.authMeGet() - } catch (ex: Exception) { - Timber.w(ex, "Error logging into %s", server.url) - seerrServerRepository.clear() - return@let - } - } - seerrServerRepository.set(server, seerrUser, userConfig) + seerrServerRepository.set(server, seerrUser, userConfig) + } } - } + } } } } 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 index 1ee5870b..eab87f20 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -85,11 +85,8 @@ class SuggestionsWorker views .mapNotNull { view -> val itemKind = - when (view.collectionType) { - CollectionType.MOVIES -> BaseItemKind.MOVIE - CollectionType.TVSHOWS -> BaseItemKind.SERIES - else -> return@mapNotNull null - } + getTypeForCollection(view.collectionType) + ?: return@mapNotNull null async(Dispatchers.IO) { runCatching { Timber.v("Fetching suggestions for view %s", view.id) @@ -267,5 +264,12 @@ class SuggestionsWorker const val WORK_NAME = "com.github.damontecres.wholphin.services.SuggestionsWorker" const val PARAM_USER_ID = "userId" const val PARAM_SERVER_ID = "serverId" + + fun getTypeForCollection(collectionType: CollectionType?): BaseItemKind? = + when (collectionType) { + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + else -> null + } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt index d8816e71..03ea0478 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/tvprovider/TvProviderWorker.kt @@ -80,7 +80,6 @@ class TvProviderWorker getPotentialItems( userId, prefs.homePagePreferences.enableRewatchingNextUp, - prefs.homePagePreferences.combineContinueNext, prefs.homePagePreferences.maxDaysNextUp, ) val potentialItemsToAddIds = potentialItemsToAdd.map { it.id.toString() } @@ -145,7 +144,6 @@ class TvProviderWorker private suspend fun getPotentialItems( userId: UUID, enableRewatching: Boolean, - combineContinueNext: Boolean, maxDaysNextUp: Int, ): List { val resumeItems = latestNextUpService.getResume(userId, 10, true) @@ -154,11 +152,7 @@ class TvProviderWorker latestNextUpService .getNextUp(userId, 10, enableRewatching, false, maxDaysNextUp) .filter { it.data.seriesId != null && it.data.seriesId !in seriesIds } - return if (combineContinueNext) { - latestNextUpService.buildCombined(resumeItems, nextUpItems) - } else { - resumeItems + nextUpItems - } + return latestNextUpService.buildCombined(resumeItems, nextUpItems) } private suspend fun getCurrentTvChannelNextUp(): List = 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 958f9dfd..0db41202 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 @@ -82,8 +82,10 @@ val PhotoItemFields = ) object Cards { - val height2x3 = 172.dp - val heightEpisode = height2x3 * .75f + const val HEIGHT_2X3_DP = 172 + val height2x3 = HEIGHT_2X3_DP.dp + val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 } + val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 70de419b..5a0985c6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -1,15 +1,19 @@ package com.github.damontecres.wholphin.ui.cards +import androidx.compose.animation.core.animateDpAsState 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.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.enableMarquee import org.jellyfin.sdk.model.api.ImageType /** @@ -60,6 +66,8 @@ fun BannerCard( cardHeight: Dp = 120.dp, aspectRatio: Float = AspectRatios.WIDE, interactionSource: MutableInteractionSource? = null, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -74,14 +82,15 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight) { + remember(item, fillHeight, imageType) { if (item != null) { - imageUrlService.getItemImageUrl( - item, - ImageType.PRIMARY, - fillWidth = null, - fillHeight = fillHeight, - ) + item.imageUrlOverride + ?: imageUrlService.getItemImageUrl( + item, + imageType, + fillWidth = null, + fillHeight = fillHeight, + ) } else { null } @@ -107,7 +116,7 @@ fun BannerCard( AsyncImage( model = imageUrl, contentDescription = null, - contentScale = ContentScale.FillBounds, + contentScale = imageContentScale, onError = { imageError = true }, modifier = Modifier.fillMaxSize(), ) @@ -181,3 +190,82 @@ fun BannerCard( } } } + +@Composable +fun BannerCardWithTitle( + title: String?, + subtitle: String?, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + cornerText: String? = null, + played: Boolean = false, + favorite: Boolean = false, + playPercent: Double = 0.0, + cardHeight: Dp = 120.dp, + aspectRatio: Float = AspectRatios.WIDE, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + imageType: ImageType = ImageType.PRIMARY, + imageContentScale: ContentScale = ContentScale.FillBounds, +) { + val focused by interactionSource.collectIsFocusedAsState() + val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) + val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) + val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) + val width = cardHeight * aspectRationToUse + Column( + verticalArrangement = Arrangement.spacedBy(spaceBetween), + modifier = modifier.width(width), + ) { + BannerCard( + name = null, + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier, + cornerText = cornerText, + played = played, + favorite = favorite, + playPercent = playPercent, + cardHeight = cardHeight, + aspectRatio = aspectRatio, + interactionSource = interactionSource, + imageType = imageType, + imageContentScale = imageContentScale, + ) + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .padding(bottom = spaceBelow) + .fillMaxWidth(), + ) { + Text( + text = title ?: "", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + Text( + text = subtitle ?: "", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Normal, + maxLines = 1, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp) + .enableMarquee(focusedAfterDelay), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt index 73cf5ba3..a32fece9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -42,11 +42,29 @@ fun GenreCard( onLongClick: () -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = GenreCard( + genreId = genre?.id, + name = genre?.name, + imageUrl = genre?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun GenreCard( + genreId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { - val background = rememberIdColor(genre?.id).copy(alpha = .6f) + val background = rememberIdColor(genreId).copy(alpha = .6f) Card( - modifier = - modifier, + modifier = modifier, onClick = onClick, onLongClick = onLongClick, interactionSource = interactionSource, @@ -63,12 +81,12 @@ fun GenreCard( .fillMaxSize() .clip(RoundedCornerShape(8.dp)), ) { - if (genre?.imageUrl.isNotNullOrBlank()) { + if (imageUrl != null) { AsyncImage( model = ImageRequest .Builder(LocalContext.current) - .data(genre.imageUrl) + .data(imageUrl) .crossfade(true) .build(), contentScale = ContentScale.FillBounds, @@ -88,7 +106,7 @@ fun GenreCard( .background(background), ) { Text( - text = genre?.name ?: "", + text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, @@ -112,7 +130,6 @@ private fun GenreCardPreview() { UUID.randomUUID(), "Adventure", null, - Color.Black, ) GenreCard( genre = genre, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index e3e6059e..c2cbd435 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect 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.graphics.Color import androidx.compose.ui.platform.LocalDensity @@ -126,21 +125,7 @@ fun SeasonCard( val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) - var focusedAfterDelay by remember { mutableStateOf(false) } - - val hideOverlayDelay = 500L - if (focused) { - LaunchedEffect(Unit) { - delay(hideOverlayDelay) - if (focused) { - focusedAfterDelay = true - } else { - focusedAfterDelay = false - } - } - } else { - focusedAfterDelay = false - } + val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val width = imageHeight * aspectRationToUse val height = imageWidth * (1f / aspectRationToUse) @@ -212,3 +197,22 @@ fun SeasonCard( } } } + +/** + * Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while + */ +@Composable +fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State { + val focused by interactionSource.collectIsFocusedAsState() + val state = remember { mutableStateOf(false) } + + LaunchedEffect(focused) { + if (!focused) { + state.value = false + return@LaunchedEffect + } + delay(500L) + state.value = true + } + return state +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt new file mode 100644 index 00000000..b04bf451 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FocusableItemRow.kt @@ -0,0 +1,78 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text + +/** + * Placeholder for [com.github.damontecres.wholphin.ui.cards.ItemRow]. It is [focusable] so it can be scrolled. + */ +@Composable +@NonRestartableComposable +fun FocusableItemRow( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + isError: Boolean = false, +) = FocusableItemRow( + titleContent = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + }, + subtitleContent = { + Text( + text = subtitle, + style = MaterialTheme.typography.titleMedium, + color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(start = 8.dp), + ) + }, + modifier = modifier, +) + +@Composable +fun FocusableItemRow( + titleContent: @Composable () -> Unit, + subtitleContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + val background by animateColorAsState( + if (focused) { + MaterialTheme.colorScheme.border.copy(alpha = .25f) + } else { + Color.Unspecified + }, + ) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .padding(start = 8.dp) + .focusable(interactionSource = interactionSource) + .background(background, shape = RoundedCornerShape(8.dp)) + .padding(8.dp), + ) { + titleContent.invoke() + subtitleContent.invoke() + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index cdf18969..84f36f02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp @@ -41,6 +41,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -102,51 +103,22 @@ class GenreViewModel .execute(api, request) .content.items .map { - Genre(it.id, it.name ?: "", null, Color.Black) + Genre(it.id, it.name ?: "", null) } withContext(Dispatchers.Main) { this@GenreViewModel.genres.value = genres loading.value = LoadingState.Success } - val genreToUrl = ConcurrentHashMap() - val semaphore = Semaphore(4) - genres - .map { genre -> - viewModelScope.async(Dispatchers.IO) { - semaphore.withPermit { - val item = - GetItemsRequestHandler - .execute( - api, - GetItemsRequest( - parentId = itemId, - recursive = true, - limit = 1, - sortBy = listOf(ItemSortBy.RANDOM), - fields = listOf(ItemFields.GENRES), - imageTypes = listOf(ImageType.BACKDROP), - imageTypeLimit = 1, - includeItemTypes = includeItemTypes, - genreIds = listOf(genre.id), - enableTotalRecordCount = false, - ), - ).content.items - .firstOrNull() - if (item != null) { - genreToUrl[genre.id] = - imageUrlService.getItemImageUrl( - itemId = item.id, - itemType = item.type, - seriesId = null, - useSeriesForPrimary = true, - imageType = ImageType.BACKDROP, - imageTags = item.imageTags.orEmpty(), - fillWidth = cardWidthPx, - ) - } - } - } - }.awaitAll() + val genreToUrl = + getGenreImageMap( + api = api, + scope = viewModelScope, + imageUrlService = imageUrlService, + genres = genres.map { it.id }, + parentId = itemId, + includeItemTypes = includeItemTypes, + cardWidthPx = cardWidthPx, + ) val genresWithImages = genres.map { it.copy( @@ -171,11 +143,62 @@ class GenreViewModel } } +suspend fun getGenreImageMap( + api: ApiClient, + scope: CoroutineScope, + imageUrlService: ImageUrlService, + genres: List, + parentId: UUID, + includeItemTypes: List?, + cardWidthPx: Int?, +): Map { + val genreToUrl = ConcurrentHashMap() + val semaphore = Semaphore(4) + genres + .map { genreId -> + scope.async(Dispatchers.IO) { + semaphore.withPermit { + val item = + GetItemsRequestHandler + .execute( + api, + GetItemsRequest( + parentId = parentId, + recursive = true, + limit = 1, + sortBy = listOf(ItemSortBy.RANDOM), + fields = listOf(ItemFields.GENRES), + imageTypes = listOf(ImageType.BACKDROP), + imageTypeLimit = 1, + includeItemTypes = includeItemTypes, + genreIds = listOf(genreId), + enableTotalRecordCount = false, + ), + ).content.items + .firstOrNull() + if (item != null) { + genreToUrl[genreId] = + imageUrlService.getItemImageUrl( + itemId = item.id, + itemType = item.type, + seriesId = null, + useSeriesForPrimary = true, + imageType = ImageType.BACKDROP, + imageTags = item.imageTags.orEmpty(), + fillWidth = cardWidthPx, + ) + } + } + } + }.awaitAll() + return genreToUrl +} + +@Stable data class Genre( val id: UUID, val name: String, val imageUrl: String?, - val color: Color, ) : CardGridItem { override val gridId: String get() = id.toString() override val playable: Boolean = false diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 28937e55..50acc56f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -1,9 +1,7 @@ package com.github.damontecres.wholphin.ui.main import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,11 +16,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -34,7 +34,6 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -48,16 +47,19 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.BannerCard +import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle +import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel @@ -71,6 +73,7 @@ import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp import com.github.damontecres.wholphin.ui.playback.playable +import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec @@ -94,12 +97,10 @@ fun HomePage( LaunchedEffect(Unit) { viewModel.init() } - val loading by viewModel.loadingState.observeAsState(LoadingState.Loading) - val refreshing by viewModel.refreshState.observeAsState(LoadingState.Loading) - val watchingRows by viewModel.watchingRows.observeAsState(listOf()) - val latestRows by viewModel.latestRows.observeAsState(listOf()) - - val homeRows = remember(watchingRows, latestRows) { watchingRows + latestRows } + val state by viewModel.state.collectAsState() + val loading = state.loadingState + val refreshing = state.refreshState + val homeRows = state.homeRows when (val state = loading) { is LoadingState.Error -> { @@ -200,6 +201,9 @@ fun HomePageContent( modifier: Modifier = Modifier, onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, + listState: LazyListState = rememberLazyListState(), + takeFocus: Boolean = true, + showEmptyRows: Boolean = false, ) { var position by rememberPosition() val focusedItem = @@ -207,37 +211,37 @@ fun HomePageContent( (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) } - val listState = rememberLazyListState() val rowFocusRequesters = remember(homeRows) { List(homeRows.size) { FocusRequester() } } var firstFocused by remember { mutableStateOf(false) } - LaunchedEffect(homeRows) { - if (!firstFocused && homeRows.isNotEmpty()) { - if (position.row >= 0) { - val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) - rowFocusRequesters.getOrNull(index)?.tryRequestFocus() - firstFocused = true - } else { - // Waiting for the first home row to load, then focus on it - homeRows - .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } - ?.let { - rowFocusRequesters[it].tryRequestFocus() - firstFocused = true - delay(50) - listState.scrollToItem(it) - } + if (takeFocus) { + LaunchedEffect(homeRows) { + if (!firstFocused && homeRows.isNotEmpty()) { + if (position.row >= 0) { + val index = position.row.coerceIn(0, rowFocusRequesters.lastIndex) + rowFocusRequesters.getOrNull(index)?.tryRequestFocus() + firstFocused = true + } else { + // Waiting for the first home row to load, then focus on it + homeRows + .indexOfFirstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + rowFocusRequesters[it].tryRequestFocus() + firstFocused = true + delay(50) + listState.scrollToItem(it) + } + } } } } + LaunchedEffect(position) { + if (position.row >= 0) { + listState.animateScrollToItem(position.row) + } + } LaunchedEffect(onUpdateBackdrop, focusedItem) { focusedItem?.let { onUpdateBackdrop.invoke(it) } } - val density = LocalDensity.current - val spaceAbovePx = - with(density) { - // The size of the row titles & spacing - 50.dp.toPx() - } Box(modifier = modifier) { Column(modifier = Modifier.fillMaxSize()) { HomePageHeader( @@ -247,6 +251,12 @@ fun HomePageContent( .padding(top = 48.dp, bottom = 32.dp, start = 8.dp) .fillMaxHeight(.33f), ) + val density = LocalDensity.current + val spaceAbovePx = + with(density) { + // The size of the row titles & spacing + 50.dp.toPx() + } val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current CompositionLocalProvider( LocalBringIntoViewSpec provides ScrollToTopBringIntoViewSpec(spaceAbovePx), @@ -263,61 +273,32 @@ fun HomePageContent( .focusRestorer(), ) { itemsIndexed(homeRows) { rowIndex, row -> - CompositionLocalProvider(LocalBringIntoViewSpec provides defaultBringIntoViewSpec) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides defaultBringIntoViewSpec, + ) { when (val r = row) { is HomeRowLoadingState.Loading, is HomeRowLoadingState.Pending, -> { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + FocusableItemRow( + title = r.title, + subtitle = stringResource(R.string.loading), modifier = Modifier.animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = stringResource(R.string.loading), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - } + ) } is HomeRowLoadingState.Error -> { - var focused by remember { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = - Modifier - .onFocusChanged { - focused = it.isFocused - }.focusable() - .background( - if (focused) { - // Just so the user can tell it has focus - MaterialTheme.colorScheme.border.copy(alpha = .25f) - } else { - Color.Unspecified - }, - ).animateItem(), - ) { - Text( - text = r.title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - text = r.localizedMessage, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - } + FocusableItemRow( + title = r.title, + subtitle = r.localizedMessage, + isError = true, + modifier = Modifier.animateItem(), + ) } is HomeRowLoadingState.Success -> { if (row.items.isNotEmpty()) { + val viewOptions = row.viewOptions ItemRow( title = row.title, items = row.items, @@ -336,26 +317,20 @@ fun HomePageContent( .focusGroup() .focusRequester(rowFocusRequesters[rowIndex]) .animateItem(), + horizontalPadding = viewOptions.spacing.dp, cardContent = { index, item, cardModifier, onClick, onLongClick -> - BannerCard( - name = item?.data?.seriesName ?: item?.name, + HomePageCardContent( + index = index, item = item, - aspectRatio = AspectRatios.TALL, - cornerText = item?.ui?.episodeUnplayedCornerText, - played = item?.data?.userData?.played ?: false, - favorite = item?.favorite ?: false, - playPercent = - item?.data?.userData?.playedPercentage - ?: 0.0, onClick = onClick, onLongClick = onLongClick, + viewOptions = viewOptions, modifier = cardModifier .onFocusChanged { if (it.isFocused) { position = RowColumn(rowIndex, index) -// item?.let(onUpdateBackdrop) } if (it.isFocused && onFocusPosition != null) { val nonEmptyRowBefore = @@ -382,11 +357,15 @@ fun HomePageContent( } return@onKeyEvent false }, - interactionSource = null, - cardHeight = Cards.height2x3, ) }, ) + } else if (showEmptyRows) { + FocusableItemRow( + title = r.title, + subtitle = stringResource(R.string.no_results), + modifier = Modifier.animateItem(), + ) } } } @@ -427,7 +406,7 @@ fun HomePageHeader( subtitle = if (isEpisode) dto?.name else null, overview = dto?.overview, overviewTwoLines = isEpisode, - quickDetails = item?.ui?.quickDetails, + quickDetails = item?.ui?.quickDetails ?: AnnotatedString(""), timeRemaining = item?.timeRemainingOrRuntime, modifier = modifier, ) @@ -488,3 +467,92 @@ fun HomePageHeader( } } } + +@Composable +fun HomePageCardContent( + index: Int, + item: BaseItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + viewOptions: HomeRowViewOptions, + modifier: Modifier, +) { + when (item?.type) { + BaseItemKind.GENRE -> { + GenreCard( + genreId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + + else -> { + val imageType = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeImageType.imageType + } else { + viewOptions.imageType.imageType + } + } + val ratio = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeAspectRatio.ratio + } else { + viewOptions.aspectRatio.ratio + } + } + val scale = + remember(item, viewOptions) { + if (item?.type == BaseItemKind.EPISODE) { + viewOptions.episodeContentScale.scale + } else { + viewOptions.contentScale.scale + } + } + if (viewOptions.showTitles) { + BannerCardWithTitle( + title = item?.title, + subtitle = item?.subtitle, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + cardHeight = viewOptions.heightDp.dp, + ) + } else { + BannerCard( + name = item?.data?.seriesName ?: item?.name, + item = item, + aspectRatio = ratio, + imageType = imageType, + imageContentScale = scale, + cornerText = item?.ui?.episodeUnplayedCornerText, + played = item?.data?.userData?.played ?: false, + favorite = item?.favorite ?: false, + playPercent = + item?.data?.userData?.playedPercentage + ?: 0.0, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = null, + cardHeight = viewOptions.heightDp.dp, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 0e571544..7ee22902 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -1,37 +1,40 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager -import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState -import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.CollectionType -import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -41,115 +44,120 @@ class HomeViewModel @Inject constructor( @param:ApplicationContext private val context: Context, - val api: ApiClient, val navigationManager: NavigationManager, val serverRepository: ServerRepository, val navDrawerItemRepository: NavDrawerItemRepository, val mediaReportService: MediaReportService, + private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, - private val latestNextUpService: LatestNextUpService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, ) : ViewModel() { - val loadingState = MutableLiveData(LoadingState.Pending) - val refreshState = MutableLiveData(LoadingState.Pending) - val watchingRows = MutableLiveData>(listOf()) - val latestRows = MutableLiveData>(listOf()) - - private lateinit var preferences: UserPreferences + private val _state = MutableStateFlow(HomeState.EMPTY) + val state: StateFlow = _state init { datePlayedService.invalidateAll() - init() +// init() } fun init() { - viewModelScope.launch( - Dispatchers.IO + - LoadingExceptionHandler( - loadingState, - "Error loading home page", - ), - ) { + viewModelScope.launchIO { Timber.d("init HomeViewModel") - val reload = loadingState.value != LoadingState.Success - if (reload) { - loadingState.setValueOnMain(LoadingState.Loading) - } - refreshState.setValueOnMain(LoadingState.Loading) - this@HomeViewModel.preferences = userPreferencesService.getCurrent() - val prefs = preferences.appPreferences.homePagePreferences - val limit = prefs.maxItemsPerRow - if (reload) { - backdropService.clearBackdrop() - } try { + val preferences = userPreferencesService.getCurrent() + val prefs = preferences.appPreferences.homePagePreferences + + val navDrawerItems = + navDrawerItemRepository + .getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } serverRepository.currentUserDto.value?.let { userDto -> - val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItemRepository.getNavDrawerItems()) - .filter { it is ServerNavDrawerItem } - .map { (it as ServerNavDrawerItem).itemId } - val resume = latestNextUpService.getResume(userDto.id, limit, true) - val nextUp = - latestNextUpService.getNextUp( - userDto.id, - limit, - prefs.enableRewatchingNextUp, - false, - prefs.maxDaysNextUp, - ) - val watching = - buildList { - if (prefs.combineContinueNext) { - val items = latestNextUpService.buildCombined(resume, nextUp) - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = items, - ), - ) - } else { - if (resume.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.continue_watching), - items = resume, - ), - ) - } - if (nextUp.isNotEmpty()) { - add( - HomeRowLoadingState.Success( - title = context.getString(R.string.next_up), - items = nextUp, - ), - ) + val settings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + val state = state.value + + // Refreshing if a load has already occurred and the rows haven't significantly changed + val refresh = + state.loadingState == LoadingState.Success && state.settings == settings + + val semaphore = Semaphore(4) + + val watchingRowIndexes = + settings.rows + .mapIndexedNotNull { index, row -> + if (isWatchingRow(row.config)) index else null + } + val deferred = + settings.rows + // Load the watching rows first + .sortedByDescending { isWatchingRow(it.config) } + .map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + Timber.v("Fetching row: %s", row) + try { + homeSettingsService.fetchDataForRow( + row = row.config, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = libraries, + limit = prefs.maxItemsPerRow, + ) + } catch (ex: Exception) { + Timber.e(ex, "Error on row %s", row) + HomeRowLoadingState.Error(row.title, exception = ex) + } + } } } - } - val latest = latestNextUpService.getLatest(userDto, limit, includedIds) - val pendingLatest = latest.map { HomeRowLoadingState.Loading(it.title) } - - withContext(Dispatchers.Main) { - this@HomeViewModel.watchingRows.value = watching - if (reload) { - this@HomeViewModel.latestRows.value = pendingLatest + if (refresh && state.homeRows.isNotEmpty() && watchingRowIndexes.isNotEmpty()) { + // Replace watching rows first + Timber.v("Refreshing rows: %s", watchingRowIndexes) + val rows = + deferred + .filterIndexed { index, _ -> index in watchingRowIndexes } + .awaitAll() + _state.update { + val newRows = + it.homeRows.toMutableList().apply { + rows.forEachIndexed { index, row -> + set(watchingRowIndexes[index], row) + } + } + it.copy( + loadingState = LoadingState.Success, + homeRows = newRows, + ) } - loadingState.value = LoadingState.Success } - refreshState.setValueOnMain(LoadingState.Success) - val loadedLatest = latestNextUpService.loadLatest(latest) - this@HomeViewModel.latestRows.setValueOnMain(loadedLatest) + val rows = deferred.awaitAll() + Timber.v("Got all rows") + _state.update { + it.copy( + loadingState = LoadingState.Success, + refreshState = LoadingState.Success, + homeRows = rows, + ) + } } } catch (ex: Exception) { - Timber.e(ex) - if (!reload) { - loadingState.setValueOnMain(LoadingState.Error(ex)) - } else { + Timber.e(ex, "Exception during home page loading") + if (state.value.loadingState == LoadingState.Success) { showToast(context, "Error refreshing home: ${ex.localizedMessage}") + } else { + _state.update { + it.copy(loadingState = LoadingState.Error(ex)) + } } } } @@ -182,16 +190,27 @@ class HomeViewModel } } -val supportedLatestCollectionTypes = - setOf( - CollectionType.MOVIES, - CollectionType.TVSHOWS, - CollectionType.HOMEVIDEOS, - // Exclude Live TV because a recording folder view will be used instead - null, // Recordings & mixed collection types - ) +data class HomeState( + val loadingState: LoadingState, + val refreshState: LoadingState, + val homeRows: List, + val settings: HomePageResolvedSettings, +) { + companion object { + val EMPTY = + HomeState( + LoadingState.Pending, + LoadingState.Pending, + listOf(), + HomePageResolvedSettings.EMPTY, + ) + } +} -data class LatestData( - val title: String, - val request: GetLatestMediaRequest, -) +/** + * Whether a row is a "is watching" type + */ +private fun isWatchingRow(row: HomeRowConfig) = + row is HomeRowConfig.ContinueWatching || + row is HomeRowConfig.NextUp || + row is HomeRowConfig.ContinueWatchingCombined diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt new file mode 100644 index 00000000..c680e077 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -0,0 +1,81 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.SuggestionsWorker +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeLibraryRowTypeList( + library: Library, + onClick: (LibraryRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { + val items = remember(library) { getSupportedRowTypes(library) } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row_for, library.name)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(items) { index, rowType -> + ListItem( + selected = false, + headlineContent = { + Text( + text = stringResource(rowType.stringId), + ) + }, + onClick = { onClick.invoke(rowType) }, + modifier = + Modifier + .fillMaxWidth() + .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} + +fun getSupportedRowTypes(library: Library): List { + val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) + return if (itemKind != null) { + LibraryRowType.entries + } else { + LibraryRowType.entries.toMutableList().apply { remove(LibraryRowType.SUGGESTIONS) } + } +} + +enum class LibraryRowType( + @param:StringRes val stringId: Int, +) { + RECENTLY_ADDED(R.string.recently_added), + RECENTLY_RELEASED(R.string.recently_released), + SUGGESTIONS(R.string.suggestions), + GENRES(R.string.genres), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt new file mode 100644 index 00000000..e0a64221 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -0,0 +1,206 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.CollectionType + +data class HomeRowPresets( + val continueWatching: HomeRowViewOptions, + val movieLibrary: HomeRowViewOptions, + val tvLibrary: HomeRowViewOptions, + val videoLibrary: HomeRowViewOptions, + val photoLibrary: HomeRowViewOptions, + val playlist: HomeRowViewOptions, + val genreSize: Int, +) { + fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = + when (collectionType) { + CollectionType.MOVIES -> movieLibrary + + CollectionType.TVSHOWS -> tvLibrary + + CollectionType.MUSICVIDEOS -> videoLibrary + + CollectionType.TRAILERS -> videoLibrary + + CollectionType.HOMEVIDEOS -> videoLibrary + + CollectionType.BOXSETS -> movieLibrary + + CollectionType.PHOTOS -> photoLibrary + + CollectionType.UNKNOWN, + CollectionType.MUSIC, + CollectionType.BOOKS, + CollectionType.LIVETV, + CollectionType.PLAYLISTS, + CollectionType.FOLDERS, + -> HomeRowViewOptions() + } + + companion object { + val WholphinDefault by lazy { + HomeRowPresets( + continueWatching = HomeRowViewOptions(), + movieLibrary = HomeRowViewOptions(), + tvLibrary = HomeRowViewOptions(), + videoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = Cards.HEIGHT_2X3_DP, + ) + } + + val WholphinCompact by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = height, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = epHeight, + ) + } + + val Thumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + genreSize = epHeight, + ) + } + } +} + +@Composable +fun HomeRowPresetsContent( + onApply: (HomeRowPresets) -> Unit, + modifier: Modifier = Modifier, +) { + val presets = + remember { + listOf( + "Wholphin Default", + "Wholphin Compact", + "Thumbnails", + ) + } + val focusRequesters = remember { List(presets.size) { FocusRequester() } } + LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.display_presets)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[0]), + ) { + itemsIndexed(presets) { index, title -> + HomeSettingsListItem( + selected = false, + headlineText = title, + onClick = { + when (index) { + 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) + 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) + 2 -> onApply.invoke(HomeRowPresets.Thumbnails) + } + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt new file mode 100644 index 00000000..9e861c61 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -0,0 +1,307 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.AppChoicePreference +import com.github.damontecres.wholphin.preferences.AppClickablePreference +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppSliderPreference +import com.github.damontecres.wholphin.preferences.AppSwitchPreference +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.preferences.PreferenceGroup +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeRowSettings( + title: String, + preferenceOptions: List>, + viewOptions: HomeRowViewOptions, + onViewOptionsChange: (HomeRowViewOptions) -> Unit, + onApplyApplyAll: () -> Unit, + modifier: Modifier = Modifier, + defaultViewOptions: HomeRowViewOptions = HomeRowViewOptions(), +) { + val firstFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(title) + LazyColumn { + preferenceOptions.forEachIndexed { groupIndex, prefGroup -> + if (preferenceOptions.size > 1) { + item { + TitleText(stringResource(prefGroup.title)) + } + } + itemsIndexed(prefGroup.preferences) { index, pref -> + pref as AppPreference + val interactionSource = remember { MutableInteractionSource() } + val value = pref.getter.invoke(viewOptions) + ComposablePreference( + preference = pref, + value = value, + onNavigate = {}, + onValueChange = { newValue -> + onViewOptionsChange.invoke(pref.setter(viewOptions, newValue)) + }, + interactionSource = interactionSource, + onClickPreference = { pref -> + when (pref) { + Options.ViewOptionsReset -> { + onViewOptionsChange.invoke(defaultViewOptions) + } + + Options.ViewOptionsApplyAll -> { + onApplyApplyAll.invoke() + } + + Options.ViewOptionsUseThumb -> { + onViewOptionsChange.invoke( + viewOptions.copy( + heightDp = Cards.HEIGHT_EPISODE, + spacing = 20, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, + episodeImageType = ViewOptionImageType.THUMB, + episodeAspectRatio = AspectRatio.WIDE, + episodeContentScale = PrefContentScale.FIT, + ), + ) + } + } + }, + modifier = + Modifier + .ifElse( + groupIndex == 0 && index == 0, + Modifier.focusRequester(firstFocus), + ), + ) + } + } + } + } +} + +internal object Options { + val ViewOptionsCardHeight = + AppSliderPreference( + title = R.string.height, + defaultValue = Cards.HEIGHT_2X3_DP.toLong(), + min = 64L, + max = Cards.HEIGHT_2X3_DP + 64L, + interval = 4, + getter = { it.heightDp.toLong() }, + setter = { prefs, value -> prefs.copy(heightDp = value.toInt()) }, + ) + val ViewOptionsSpacing = + AppSliderPreference( + title = R.string.spacing, + defaultValue = 16, + min = 0, + max = 32, + interval = 2, + getter = { it.spacing.toLong() }, + setter = { prefs, value -> prefs.copy(spacing = value.toInt()) }, + ) + + val ViewOptionsContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(contentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.aspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(aspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsShowTitles = + AppSwitchPreference( + title = R.string.show_titles, + defaultValue = true, + getter = { it.showTitles }, + setter = { vo, value -> vo.copy(showTitles = value) }, + ) + + val ViewOptionsUseSeries = + AppSwitchPreference( + title = R.string.use_series, + defaultValue = true, + getter = { it.useSeries }, + setter = { vo, value -> vo.copy(useSeries = value) }, + ) + + val ViewOptionsImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(imageType = value, aspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsApplyAll = + AppClickablePreference( + title = R.string.apply_all_rows, + ) + + val ViewOptionsReset = + AppClickablePreference( + title = R.string.reset, + ) + + val ViewOptionsUseThumb = + AppClickablePreference( + title = R.string.use_thumb_images, + ) + + val ViewOptionsEpisodeContentScale = + AppChoicePreference( + title = R.string.global_content_scale, + defaultValue = PrefContentScale.FIT, + displayValues = R.array.content_scale, + getter = { it.contentScale }, + setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, + indexToValue = { PrefContentScale.forNumber(it) }, + valueToIndex = { it.number }, + ) + + val ViewOptionsEpisodeAspectRatio = + AppChoicePreference( + title = R.string.aspect_ratio, + defaultValue = AspectRatio.TALL, + displayValues = R.array.aspect_ratios, + getter = { it.episodeAspectRatio }, + setter = { viewOptions, value -> viewOptions.copy(episodeAspectRatio = value) }, + indexToValue = { AspectRatio.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val ViewOptionsEpisodeImageType = + AppChoicePreference( + title = R.string.image_type, + defaultValue = ViewOptionImageType.PRIMARY, + displayValues = R.array.image_types, + getter = { it.imageType }, + setter = { viewOptions, value -> + val aspectRatio = + when (value) { + ViewOptionImageType.PRIMARY -> AspectRatio.TALL + ViewOptionImageType.THUMB -> AspectRatio.WIDE + } + viewOptions.copy(episodeImageType = value, episodeAspectRatio = aspectRatio) + }, + indexToValue = { ViewOptionImageType.entries[it] }, + valueToIndex = { it.ordinal }, + ) + + val OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ViewOptionsUseSeries, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( +// ViewOptionsApplyAll, + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val OPTIONS_EPISODES = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsShowTitles, + ViewOptionsImageType, + ViewOptionsAspectRatio, + ViewOptionsContentScale, + ), + ), + PreferenceGroup( + title = R.string.for_episodes, + preferences = + listOf( + ViewOptionsUseSeries, + ViewOptionsEpisodeImageType, + ViewOptionsEpisodeAspectRatio, + ViewOptionsEpisodeContentScale, + ), + ), + PreferenceGroup( + title = R.string.more, + preferences = + listOf( + ViewOptionsUseThumb, + ViewOptionsReset, + ), + ), + ) + + val GENRE_OPTIONS = + listOf( + PreferenceGroup( + title = R.string.general, + preferences = + listOf( + ViewOptionsCardHeight, + ViewOptionsSpacing, + ViewOptionsReset, + ), + ), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt new file mode 100644 index 00000000..ab48de30 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -0,0 +1,102 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.ifElse + +@Composable +fun HomeSettingsAddRow( + libraries: List, + showDiscover: Boolean, + onClick: (Library) -> Unit, + onClickMeta: (MetaRowType) -> Unit, + modifier: Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { +// LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText(stringResource(R.string.add_row)) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed( + listOf( + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + ), + ) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.library)) + HorizontalDivider() + } + itemsIndexed(libraries) { index, library -> + HomeSettingsListItem( + selected = false, + headlineText = library.name, + onClick = { onClick.invoke(library) }, + modifier = Modifier, // .ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + item { + TitleText(stringResource(R.string.more)) + HorizontalDivider() + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.FAVORITES.stringId), + onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) }, + modifier = Modifier, + ) + } + if (showDiscover) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(MetaRowType.DISCOVER.stringId), + onClick = { onClickMeta.invoke(MetaRowType.DISCOVER) }, + modifier = Modifier, + ) + } + } + } + } +} + +enum class MetaRowType( + @param:StringRes val stringId: Int, +) { + CONTINUE_WATCHING(R.string.continue_watching), + NEXT_UP(R.string.next_up), + COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), + FAVORITES(R.string.favorites), + DISCOVER(R.string.discover), +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt new file mode 100644 index 00000000..ad433c23 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsDestination.kt @@ -0,0 +1,38 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +/** + * Tracking the pages for selecting and configuring rows + */ +@Serializable +sealed interface HomeSettingsDestination : NavKey { + @Serializable + data object RowList : HomeSettingsDestination + + @Serializable + data object AddRow : HomeSettingsDestination + + @Serializable + data class ChooseRowType( + val library: Library, + ) : HomeSettingsDestination + + @Serializable + data class RowSettings( + val rowId: Int, + ) : HomeSettingsDestination + + @Serializable + data object ChooseFavorite : HomeSettingsDestination + + @Serializable + data object ChooseDiscover : HomeSettingsDestination + + @Serializable + data object GlobalSettings : HomeSettingsDestination + + @Serializable + data object Presets : HomeSettingsDestination +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt new file mode 100644 index 00000000..3bac590a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsFavoriteList.kt @@ -0,0 +1,64 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun HomeSettingsFavoriteList( + onClick: (BaseItemKind) -> Unit, + modifier: Modifier = Modifier, + firstFocus: FocusRequester = remember { FocusRequester() }, +) { + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + TitleText( + stringResource(R.string.add_row_for, stringResource(R.string.favorites)), + ) + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + itemsIndexed(favoriteOptionsList) { index, type -> + HomeSettingsListItem( + selected = false, + headlineText = stringResource(favoriteOptions[type]!!), + onClick = { onClick.invoke(type) }, + modifier = Modifier.ifElse(index == 0, Modifier.focusRequester(firstFocus)), + ) + } + } + } +} + +val favoriteOptions by lazy { + mapOf( + BaseItemKind.MOVIE to R.string.movies, + BaseItemKind.SERIES to R.string.tv_shows, + BaseItemKind.EPISODE to R.string.episodes, + BaseItemKind.VIDEO to R.string.videos, + BaseItemKind.PLAYLIST to R.string.playlists, + BaseItemKind.PERSON to R.string.people, + ) +} +val favoriteOptionsList by lazy { favoriteOptions.keys.toList() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt new file mode 100644 index 00000000..e4b61881 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsGlobal.kt @@ -0,0 +1,184 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.preferences.AppPreference +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.preferences.ComposablePreference +import com.github.damontecres.wholphin.ui.tryRequestFocus + +@Composable +fun HomeSettingsGlobal( + preferences: AppPreferences, + onPreferenceChange: (AppPreferences) -> Unit, + onClickResize: (Int) -> Unit, + onClickSave: () -> Unit, + onClickLoad: () -> Unit, + onClickLoadWeb: () -> Unit, + onClickReset: () -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus: FocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { firstFocus.tryRequestFocus() } + Column(modifier = modifier) { + Text( + text = stringResource(R.string.settings), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + HorizontalDivider() + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(firstFocus), + ) { + item { + ComposablePreference( + preference = AppPreference.HomePageItems, + value = AppPreference.HomePageItems.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.HomePageItems.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier.focusRequester(firstFocus), + ) + } + item { + ComposablePreference( + preference = AppPreference.RewatchNextUp, + value = AppPreference.RewatchNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.RewatchNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier, + ) + } + item { + ComposablePreference( + preference = AppPreference.MaxDaysNextUp, + value = AppPreference.MaxDaysNextUp.getter.invoke(preferences), + onValueChange = { + val newPrefs = AppPreference.MaxDaysNextUp.setter.invoke(preferences, it) + onPreferenceChange.invoke(newPrefs) + }, + onNavigate = {}, + modifier = Modifier, + ) + } + item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.increase_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowUp, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(1) }, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.decrease_all_cards_size), + leadingContent = { + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + }, + onClick = { onClickResize.invoke(-1) }, + modifier = Modifier, + ) + } + item { HorizontalDivider() } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.save_to_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_up), + fontFamily = FontAwesome, + ) + }, + onClick = onClickSave, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_server), + leadingContent = { + Text( + text = stringResource(R.string.fa_cloud_arrow_down), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoad, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.load_from_web_client), + leadingContent = { + Text( + text = stringResource(R.string.fa_download), + fontFamily = FontAwesome, + ) + }, + onClick = onClickLoadWeb, + modifier = Modifier, + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.reset), + leadingContent = { + Text( + text = stringResource(R.string.fa_arrows_rotate), + fontFamily = FontAwesome, + ) + }, + onClick = onClickReset, + modifier = Modifier, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt new file mode 100644 index 00000000..4f265dbb --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsListItem.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ListItem +import androidx.tv.material3.ListItemBorder +import androidx.tv.material3.ListItemColors +import androidx.tv.material3.ListItemDefaults +import androidx.tv.material3.ListItemGlow +import androidx.tv.material3.ListItemScale +import androidx.tv.material3.ListItemShape +import com.github.damontecres.wholphin.ui.preferences.PreferenceTitle + +@Composable +@NonRestartableComposable +fun HomeSettingsListItem( + selected: Boolean, + onClick: () -> Unit, + headlineText: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onLongClick: (() -> Unit)? = null, + overlineContent: (@Composable () -> Unit)? = null, + supportingContent: (@Composable () -> Unit)? = null, + leadingContent: (@Composable BoxScope.() -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null, + tonalElevation: Dp = 3.dp, + shape: ListItemShape = ListItemDefaults.shape(), + colors: ListItemColors = ListItemDefaults.colors(), + scale: ListItemScale = ListItemDefaults.scale(), + border: ListItemBorder = ListItemDefaults.border(), + glow: ListItemGlow = ListItemDefaults.glow(), + interactionSource: MutableInteractionSource? = null, +) = ListItem( + selected = selected, + onClick = onClick, + headlineContent = { + PreferenceTitle(headlineText) + }, + modifier = modifier, + enabled = enabled, + onLongClick = onLongClick, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = trailingContent, + tonalElevation = tonalElevation, + shape = shape, + colors = colors, + scale = scale, + border = border, + glow = glow, + interactionSource = interactionSource, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt new file mode 100644 index 00000000..616eff13 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -0,0 +1,290 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.surfaceColorAtElevation +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.HomePageContent +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import timber.log.Timber + +val settingsWidth = 360.dp + +@Composable +fun HomeSettingsPage( + modifier: Modifier, + viewModel: HomeSettingsViewModel = hiltViewModel(), +) { + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) + var showConfirmDialog by remember { mutableStateOf(null) } + + val state by viewModel.state.collectAsState() + // TODO discover rows + val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) + + // Adds a row, waits until its done loading, then scrolls to the new row + fun addRow(func: () -> Job) { + scope.launch(ExceptionHandler(autoToast = true)) { + backStack.add(HomeSettingsDestination.RowList) + func.invoke().join() + listState.animateScrollToItem(state.rows.lastIndex) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + modifier = + Modifier + .width(settingsWidth) + .fillMaxHeight() + .background(color = MaterialTheme.colorScheme.surface), + ) { + NavDisplay( + backStack = backStack, +// onBack = { navigationManager.goBack() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), + entryProvider = { key -> + val dest = key as HomeSettingsDestination + NavEntry(dest, contentKey = key.toString()) { + val destModifier = + Modifier + .fillMaxSize() + .padding(8.dp) + when (dest) { + HomeSettingsDestination.RowList -> { + HomeSettingsRowList( + state = state, + onClickAdd = { backStack.add(HomeSettingsDestination.AddRow) }, + onClickSettings = { backStack.add(HomeSettingsDestination.GlobalSettings) }, + onClickPresets = { backStack.add(HomeSettingsDestination.Presets) }, + onClickMove = viewModel::moveRow, + onClickDelete = viewModel::deleteRow, + onClick = { index, row -> + backStack.add(RowSettings(row.id)) + scope.launch(ExceptionHandler()) { + Timber.v("Scroll to $index") + listState.scrollToItem(index) + } + }, + modifier = destModifier, + ) + } + + is HomeSettingsDestination.AddRow -> { + HomeSettingsAddRow( + libraries = state.libraries, + showDiscover = discoverEnabled, + onClick = { backStack.add(ChooseRowType(it)) }, + onClickMeta = { + when (it) { + MetaRowType.CONTINUE_WATCHING, + MetaRowType.NEXT_UP, + MetaRowType.COMBINED_CONTINUE_WATCHING, + -> { + addRow { viewModel.addRow(it) } + } + + MetaRowType.FAVORITES -> { + backStack.add(HomeSettingsDestination.ChooseFavorite) + } + + MetaRowType.DISCOVER -> { + backStack.add(HomeSettingsDestination.ChooseDiscover) + } + } + }, + modifier = destModifier, + ) + } + + is ChooseRowType -> { + HomeLibraryRowTypeList( + library = dest.library, + onClick = { type -> + addRow { viewModel.addRow(dest.library, type) } + }, + modifier = destModifier, + ) + } + + is RowSettings -> { + val row = + state.rows + .first { it.id == dest.rowId } + val preferenceOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.ContinueWatching, + is HomeRowConfig.ContinueWatchingCombined, + -> Options.OPTIONS_EPISODES + + is HomeRowConfig.Genres -> Options.GENRE_OPTIONS + + else -> Options.OPTIONS + } + } + val defaultViewOptions = + remember(row.config) { + when (row.config) { + is HomeRowConfig.Genres -> HomeRowViewOptions.genreDefault + else -> HomeRowViewOptions() + } + } + HomeRowSettings( + title = row.title, + preferenceOptions = preferenceOptions, + viewOptions = row.config.viewOptions, + defaultViewOptions = defaultViewOptions, + onViewOptionsChange = { + viewModel.updateViewOptions(dest.rowId, it) + }, + onApplyApplyAll = { + viewModel.updateViewOptionsForAll(row.config.viewOptions) + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.ChooseDiscover -> { + TODO() + } + + HomeSettingsDestination.ChooseFavorite -> { + HomeSettingsFavoriteList( + onClick = { type -> + addRow { viewModel.addFavoriteRow(type) } + }, + ) + } + + HomeSettingsDestination.GlobalSettings -> { + val preferences by + viewModel.preferencesDataStore.data.collectAsState( + AppPreferences.getDefaultInstance(), + ) + + HomeSettingsGlobal( + preferences = preferences, + onPreferenceChange = { newPrefs -> + scope.launchIO { + viewModel.preferencesDataStore.updateData { newPrefs } + } + }, + onClickResize = { viewModel.resizeCards(it) }, + onClickSave = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_server_settings) { + viewModel.saveToRemote() + } + }, + onClickLoad = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemote() + } + }, + onClickLoadWeb = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.loadFromRemoteWeb() + } + }, + onClickReset = { + showConfirmDialog = + ShowConfirm(R.string.overwrite_local_settings) { + viewModel.resetToDefault() + } + }, + modifier = destModifier, + ) + } + + HomeSettingsDestination.Presets -> { + HomeRowPresetsContent( + onApply = viewModel::applyPreset, + modifier = destModifier, + ) + } + } + } + }, + ) + } + HomePageContent( + loadingState = state.loading, + homeRows = state.rowData, + onClickItem = { _, _ -> }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + showClock = false, + onUpdateBackdrop = viewModel::updateBackdrop, + listState = listState, + takeFocus = false, + showEmptyRows = true, + modifier = + Modifier + .fillMaxHeight() + .weight(1f), + ) + } + showConfirmDialog?.let { (body, onConfirm) -> + ConfirmDialog( + title = stringResource(R.string.confirm), + body = stringResource(body), + onCancel = { showConfirmDialog = null }, + onConfirm = { + onConfirm.invoke() + showConfirmDialog = null + }, + ) + } +} + +data class ShowConfirm( + @param:StringRes val body: Int, + val onConfirm: () -> Unit, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt new file mode 100644 index 00000000..231243ce --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -0,0 +1,269 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.services.HomeRowConfigDisplay +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.tryRequestFocus +import kotlinx.coroutines.launch + +enum class MoveDirection { + UP, + DOWN, +} + +@Composable +fun HomeSettingsRowList( + state: HomePageSettingsState, + onClick: (Int, HomeRowConfigDisplay) -> Unit, + onClickAdd: () -> Unit, + onClickSettings: () -> Unit, + onClickPresets: () -> Unit, + onClickMove: (MoveDirection, Int) -> Unit, + onClickDelete: (Int) -> Unit, + modifier: Modifier, +) { + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + + val itemsBeforeRows = 4 + val focusRequesters = + remember(state.rows.size) { List(itemsBeforeRows + state.rows.size) { FocusRequester() } } + + var position by rememberInt(0) + + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Column(modifier = modifier) { + TitleText(stringResource(R.string.customize_home)) + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = + modifier + .fillMaxHeight() + .focusRestorer(focusRequesters[0]), + ) { + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.add_row), + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + ) + }, + onClick = { + position = 0 + onClickAdd.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[0]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.settings), + leadingContent = { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = null, + ) + }, + onClick = { + position = 1 + onClickSettings.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[1]), + ) + } + item { + HomeSettingsListItem( + selected = false, + headlineText = stringResource(R.string.display_presets), + supportingContent = { + Text( + text = stringResource(R.string.display_presets_description), + ) + }, + leadingContent = { + Text( + text = stringResource(R.string.fa_sliders), + fontFamily = FontAwesome, + ) + }, + onClick = { + position = 2 + onClickPresets.invoke() + }, + modifier = Modifier.focusRequester(focusRequesters[1]), + ) + } + item { + TitleText(stringResource(R.string.home_rows)) + HorizontalDivider() + } + itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> + HomeRowConfigContent( + config = row, + moveUpAllowed = index > 0, + moveDownAllowed = index != state.rows.lastIndex, + deleteAllowed = state.rows.size > 1, + onClickMove = { + onClickMove.invoke(it, index) + scope.launch { + val scrollIndex = + itemsBeforeRows + if (it == MoveDirection.UP) index - 1 else index + 1 + if (scrollIndex < listState.firstVisibleItemIndex || + scrollIndex > listState.layoutInfo.visibleItemsInfo.lastIndex + ) { + listState.animateScrollToItem(scrollIndex) + } + } + }, + onClickDelete = { + if (index != state.rows.lastIndex) { + focusManager.moveFocus(FocusDirection.Down) + } else { + focusManager.moveFocus(FocusDirection.Up) + } + onClickDelete.invoke(index) + }, + onClick = { + position = itemsBeforeRows + index + onClick.invoke(index, row) + }, + modifier = + Modifier + .fillMaxWidth() + .animateItem() + .focusRequester(focusRequesters[itemsBeforeRows + index]), + ) + } + } + } +} + +@Composable +fun HomeRowConfigContent( + config: HomeRowConfigDisplay, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + deleteAllowed: Boolean, + onClick: () -> Unit, + onClickMove: (MoveDirection) -> Unit, + onClickDelete: () -> Unit, + modifier: Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + HomeSettingsListItem( + selected = false, + headlineText = config.title, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + Button( + onClick = { onClickMove.invoke(MoveDirection.UP) }, + enabled = moveUpAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_up), + fontFamily = FontAwesome, + ) + } + Button( + onClick = { onClickMove.invoke(MoveDirection.DOWN) }, + enabled = moveDownAllowed, + ) { + Text( + text = stringResource(R.string.fa_caret_down), + fontFamily = FontAwesome, + ) + } + Button( + onClick = onClickDelete, + enabled = deleteAllowed, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +@NonRestartableComposable +fun TitleText( + title: String, + modifier: Modifier = Modifier, +) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Start, + modifier = + modifier + .fillMaxWidth() + .padding(top = 8.dp, bottom = 4.dp), + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt new file mode 100644 index 00000000..3a5f088a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -0,0 +1,670 @@ +package com.github.damontecres.wholphin.ui.main.settings + +import android.content.Context +import android.widget.Toast +import androidx.compose.runtime.Immutable +import androidx.datastore.core.DataStore +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomePageSettings +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatching +import com.github.damontecres.wholphin.data.model.HomeRowConfig.ContinueWatchingCombined +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Genres +import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded +import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased +import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.HomePageResolvedSettings +import com.github.damontecres.wholphin.services.HomeRowConfigDisplay +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.SeerrServerRepository +import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.showToast +import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.serialization.Serializable +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.serializer.UUIDSerializer +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject +import kotlin.properties.Delegates + +@HiltViewModel +class HomeSettingsViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val homeSettingsService: HomeSettingsService, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + private val navDrawerItemRepository: NavDrawerItemRepository, + private val backdropService: BackdropService, + private val seerrServerRepository: SeerrServerRepository, + val preferencesDataStore: DataStore, + @param:IoCoroutineScope private val ioScope: CoroutineScope, + ) : ViewModel() { + private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) + val state: StateFlow = _state + + private var idCounter by Delegates.notNull() + + val discoverEnabled = seerrServerRepository.active + + init { + addCloseable { saveToLocal() } + viewModelScope.launchIO { + val navDrawerItems = + navDrawerItemRepository + .getNavDrawerItems() + val libraries = + navDrawerItems + .filter { it is ServerNavDrawerItem } + .map { + it as ServerNavDrawerItem + Library(it.itemId, it.name, it.type) + } + val currentSettings = + homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + Timber.v("currentSettings=%s", currentSettings) + idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 + _state.update { + it.copy( + libraries = libraries, + rows = currentSettings.rows, + ) + } + fetchRowData() + } + } + + fun updateBackdrop(item: BaseItem) { + viewModelScope.launchIO { + backdropService.submit(item) + } + } + + private suspend fun fetchRowData() { + val limit = 6 + val semaphore = Semaphore(4) + val rows = + serverRepository.currentUserDto.value?.let { userDto -> + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + state.value + .let { state -> + state.rows + .map { it.config } + .map { row -> + viewModelScope.async(Dispatchers.IO) { + semaphore.withPermit { + homeSettingsService.fetchDataForRow( + row = row, + scope = viewModelScope, + prefs = prefs, + userDto = userDto, + libraries = state.libraries, + limit = limit, + ) + } + } + } + }.awaitAll() + } + rows?.let { rows -> + rows + .firstOrNull { it is HomeRowLoadingState.Success && it.items.isNotEmpty() } + ?.let { + it as HomeRowLoadingState.Success + it.items.firstOrNull()?.let { + Timber.v("Updating backdrop") + updateBackdrop(it) + } + } + updateState { + it.copy(loading = LoadingState.Success, rowData = rows) + } + } + } + + private fun List.move( + direction: MoveDirection, + index: Int, + ): List = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + + fun moveRow( + direction: MoveDirection, + index: Int, + ) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.move(direction, index) + val rowData = it.rowData.move(direction, index) + it.copy( + rows = rows, + rowData = rowData, + ) + } + } +// viewModelScope.launchIO { fetchRowData() } + } + + fun deleteRow(index: Int) { + viewModelScope.launchIO { + updateState { + val rows = it.rows.toMutableList().apply { removeAt(index) } + val rowData = it.rowData.toMutableList().apply { removeAt(index) } + it.copy( + rows = rows, + rowData = rowData, + ) + } + } + } + + fun addRow(type: MetaRowType): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (type) { + MetaRowType.CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.continue_watching), + config = ContinueWatching(), + ) + } + + MetaRowType.NEXT_UP -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.continue_watching), + config = NextUp(), + ) + } + + MetaRowType.COMBINED_CONTINUE_WATCHING -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.combine_continue_next), + config = ContinueWatchingCombined(), + ) + } + + MetaRowType.FAVORITES -> { + throw IllegalArgumentException("Should use addRow(BaseItemKind) instead") + } + + MetaRowType.DISCOVER -> { + TODO() + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addRow( + library: Library, + rowType: LibraryRowType, + ): Job = + viewModelScope.launchIO { + val id = idCounter++ + val newRow = + when (rowType) { + LibraryRowType.RECENTLY_ADDED -> { + val title = + library.name.let { context.getString(R.string.recently_added_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } + + LibraryRowType.RECENTLY_RELEASED -> { + val title = + library.name.let { + context.getString( + R.string.recently_released_in, + it, + ) + } + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyReleased(library.itemId), + ) + } + + LibraryRowType.GENRES -> { + val title = library.name.let { context.getString(R.string.genres_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Genres(library.itemId), + ) + } + + LibraryRowType.SUGGESTIONS -> { + val title = + library.name.let { context.getString(R.string.suggestions_for, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = Suggestions(library.itemId), + ) + } + } + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun addFavoriteRow(type: BaseItemKind): Job = + viewModelScope.launchIO { + Timber.v("Adding favorite row for $type") + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = context.getString(favoriteOptions[type]!!), + config = HomeRowConfig.Favorite(type), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + + fun updateViewOptions( + rowId: Int, + viewOptions: HomeRowViewOptions, + ) { + viewModelScope.launchIO { + var fetchData = false + updateState { + val index = it.rows.indexOfFirst { it.id == rowId } + val config = it.rows[index].config + val newRowConfig = config.updateViewOptions(viewOptions) + val newRow = it.rows[index].copy(config = newRowConfig) + if (config.viewOptions.useSeries != viewOptions.useSeries) { + fetchData = true + } + it.copy( + rows = + it.rows.toMutableList().apply { + set(index, newRow) + }, + rowData = + it.rowData.toMutableList().apply { + val row = it.rowData[index] + val newRow = + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + set(index, newRow) + }, + ) + } + if (fetchData) { + fetchRowData() + } + } + } + + fun updateViewOptionsForAll(viewOptions: HomeRowViewOptions) { + viewModelScope.launchIO { + updateState { + it.copy( + rowData = + it.rowData.toMutableList().map { row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun saveToRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Saving home settings to remote") + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToRemote") + homeSettingsService.saveToServer(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemote() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from remote") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.loadFromServer(user.id) + if (result != null) { + Timber.v("Got remote settings") + val newRows = + result.rows.mapIndexed { index, config -> + homeSettingsService.resolve(index, config) + } + _state.update { + it.copy(rows = newRows) + } + } else { + Timber.v("No remote settings") + showToast(context, "No server-side settings found") + } + fetchRowData() + } catch (ex: UnsupportedHomeSettingsVersionException) { + // TODO + Timber.w(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun loadFromRemoteWeb() { + viewModelScope.launchIO { + serverRepository.currentUser.value?.let { user -> + Timber.d("Loading home settings from web") + try { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.parseFromWebConfig(user.id) + if (result != null) { + Timber.v("Got web settings") + _state.update { + it.copy(rows = result.rows) + } + } else { + Timber.v("No web settings") + showToast(context, "No server-side web settings found") + } + fetchRowData() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error: ${ex.localizedMessage}") + } + } + } + } + + fun saveToLocal() { + // This uses injected ioScope so that it will still run when the page is closing + ioScope.launchIO { + serverRepository.currentUser.value?.let { user -> + val rows = state.value.rows.map { it.config } + val settings = + HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) + try { + Timber.d("saveToLocal") + val local = homeSettingsService.loadFromLocal(user.id) + // Only save if there are changes + if (local != settings) { + homeSettingsService.saveToLocal(user.id, settings) + homeSettingsService.updateCurrent(settings) + showSaveToast() + } else { + Timber.d("No changes") + } + } catch (ex: UnsupportedHomeSettingsVersionException) { + Timber.w(ex, "Overwriting local settings") + homeSettingsService.saveToLocal(user.id, settings) + showSaveToast() + } catch (ex: Exception) { + Timber.e(ex) + showToast(context, "Error saving: ${ex.localizedMessage}") + } + } + } + } + + private fun updateState(update: (HomePageSettingsState) -> HomePageSettingsState) { + _state.update { + update.invoke(it) + } + homeSettingsService.currentSettings.update { HomePageResolvedSettings(state.value.rows) } + } + + fun resizeCards(relative: Int) { + viewModelScope.launchIO { + updateState { + val newRows = + it.rows.toMutableList().map { row -> + val vo = row.config.viewOptions + val newVo = vo.copy(heightDp = vo.heightDp + (4 * relative)) + row.copy(config = row.config.updateViewOptions(newVo)) + } + it.copy( + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + + fun resetToDefault() { + viewModelScope.launchIO { + _state.update { it.copy(loading = LoadingState.Loading) } + val result = homeSettingsService.createDefault() + _state.update { + it.copy(rows = result.rows) + } + fetchRowData() + } + } + + private suspend fun showSaveToast() = + showToast( + context, + context.getString(R.string.settings_saved), + Toast.LENGTH_SHORT, + ) + + fun applyPreset(preset: HomeRowPresets) { + _state.update { it.copy(loading = LoadingState.Loading) } + viewModelScope.launchIO { + val state = state.value + + val typeCache = mutableMapOf() + + suspend fun getCollectionType(itemId: UUID): CollectionType = + typeCache.getOrPut(itemId) { + state.libraries + .firstOrNull { it.itemId == itemId } + ?.collectionType + ?: api.userLibraryApi + .getItem(itemId) + .content.collectionType ?: CollectionType.UNKNOWN + } ?: CollectionType.UNKNOWN + + val newRows = + state.rows.map { + val newConfig = + when (it.config) { + is ContinueWatching, + is NextUp, + is ContinueWatchingCombined, + -> { + it.config.updateViewOptions(preset.continueWatching) + } + + is HomeRowConfig.ByParent -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Favorite -> { + val viewOptions = + when (it.config.kind) { + BaseItemKind.MOVIE -> preset.movieLibrary + BaseItemKind.SERIES -> preset.tvLibrary + BaseItemKind.EPISODE -> preset.continueWatching + BaseItemKind.VIDEO -> preset.videoLibrary + BaseItemKind.PLAYLIST -> preset.playlist + BaseItemKind.PERSON -> preset.movieLibrary + else -> preset.movieLibrary + } + it.config.updateViewOptions(viewOptions) + } + + is Genres -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + + is HomeRowConfig.GetItems -> { + it.config + } + + is RecentlyAdded -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is RecentlyReleased -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.Recordings -> { + it.config.updateViewOptions(preset.tvLibrary) + } + + is Suggestions -> { + val collectionType = getCollectionType(it.config.parentId) + val viewOptions = preset.getByCollectionType(collectionType) + it.config.updateViewOptions(viewOptions) + } + + is HomeRowConfig.TvPrograms -> { + it.config.updateViewOptions(preset.tvLibrary) + } + } + it.copy(config = newConfig) + } + + _state.update { + it.copy( + loading = LoadingState.Success, + rows = newRows, + rowData = + it.rowData.toMutableList().mapIndexed { index, row -> + if (row is HomeRowLoadingState.Success) { + row.copy(viewOptions = newRows[index].config.viewOptions) + } else { + row + } + }, + ) + } + } + } + } + +data class HomePageSettingsState( + val loading: LoadingState, + val rows: List, + val rowData: List, + val libraries: List, +) { + companion object { + val EMPTY = + HomePageSettingsState( + LoadingState.Pending, + listOf(), + listOf(), + listOf(), + ) + } +} + +@Immutable +@Serializable +data class Library( + @Serializable(UUIDSerializer::class) val itemId: UUID, + val name: String, + val collectionType: CollectionType, +) 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 0d16f36a..59d102c4 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 @@ -33,6 +33,9 @@ sealed class Destination( val id: Long = 0L, ) : Destination() + @Serializable + data object HomeSettings : Destination(true) + @Serializable data class Settings( val screen: PreferenceScreenOption, 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 c1e70880..a16e909a 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 @@ -33,6 +33,7 @@ import com.github.damontecres.wholphin.ui.detail.series.SeriesOverview import com.github.damontecres.wholphin.ui.discover.DiscoverPage import com.github.damontecres.wholphin.ui.main.HomePage import com.github.damontecres.wholphin.ui.main.SearchPage +import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsPage 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 @@ -63,6 +64,10 @@ fun DestinationContent( ) } + is Destination.HomeSettings -> { + HomeSettingsPage(modifier) + } + is Destination.PlaybackList, is Destination.Playback, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index c0e93556..da246f73 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -34,6 +34,14 @@ val supportedCollectionTypes = null, // Mixed ) +val supportedHomeCollectionTypes = + setOf( + CollectionType.MOVIES, + CollectionType.TVSHOWS, + CollectionType.HOMEVIDEOS, + null, // Mixed + ) + val supportedPlayableTypes = setOf( BaseItemKind.MOVIE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt index 1a4ca1aa..5da98fec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/LoadingState.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.util import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions /** * Generic state for loading something from the API @@ -62,6 +63,7 @@ sealed interface HomeRowLoadingState { data class Success( override val title: String, val items: List, + val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), ) : HomeRowLoadingState data class Error( diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index 371560a1..38cf32c1 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -51,4 +51,6 @@ + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b8fdc1fc..e8533441 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -498,6 +498,29 @@ No limit Max days in Next Up + Add row + Genres in %1$s + Recently released in %1$s + Height + Apply to all rows + Customize home page + Home rows + Load from server + Save to server + Load from web client + Use series image + Add row for %1$s + Overwrite settings on server? + Overwrite local settings? + For episodes + Suggestions for %1$s + Increase size for all cards + Decrease size for all cards + Use thumb images + Settings saved + Display presets + Built-in presets to quickly style all rows + Disabled Lowest diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt new file mode 100644 index 00000000..3aedb3ba --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -0,0 +1,150 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.data.model.HomeRowViewOptions +import com.github.damontecres.wholphin.preferences.PrefContentScale +import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.ui.AspectRatio +import com.github.damontecres.wholphin.ui.components.ViewOptionImageType +import com.github.damontecres.wholphin.ui.data.SortAndDirection +import io.mockk.mockk +import kotlinx.serialization.json.Json +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.SortOrder +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.junit.Assert +import org.junit.Test +import kotlin.reflect.KClass + +class TestHomeRowSamples { + companion object { + val SAMPLES = + listOf( + HomeRowConfig.RecentlyAdded( + parentId = UUID.randomUUID(), + viewOptions = + HomeRowViewOptions( + heightDp = 100, + spacing = 8, + contentScale = PrefContentScale.CROP, + aspectRatio = AspectRatio.FOUR_THREE, + imageType = ViewOptionImageType.THUMB, + showTitles = false, + useSeries = false, + ), + ), + HomeRowConfig.RecentlyReleased( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Genres( + parentId = UUID.randomUUID(), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatching( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.NextUp( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ContinueWatchingCombined( + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.ByParent( + parentId = UUID.randomUUID(), + recursive = true, + sort = SortAndDirection(ItemSortBy.CRITIC_RATING, SortOrder.ASCENDING), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.GetItems( + name = "Episodes by date created", + getItems = + GetItemsRequest( + parentId = UUID.randomUUID(), + recursive = true, + isFavorite = true, + includeItemTypes = listOf(BaseItemKind.EPISODE), + sortBy = listOf(ItemSortBy.DATE_CREATED), + sortOrder = listOf(SortOrder.DESCENDING), + ), + viewOptions = HomeRowViewOptions(), + ), + HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), + HomeRowConfig.Recordings(), + HomeRowConfig.TvPrograms(), + HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), + ) + } + + @Test + fun `Check all types have a sample`() { + // This ensures there is a sample for each possible HomeRowConfig type + val foundTypes = mutableSetOf>() + SAMPLES.forEach { + when (it) { + is HomeRowConfig.ContinueWatching -> foundTypes.add(it::class) + is HomeRowConfig.ContinueWatchingCombined -> foundTypes.add(it::class) + is HomeRowConfig.Genres -> foundTypes.add(it::class) + is HomeRowConfig.NextUp -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyAdded -> foundTypes.add(it::class) + is HomeRowConfig.RecentlyReleased -> foundTypes.add(it::class) + is HomeRowConfig.ByParent -> foundTypes.add(it::class) + is HomeRowConfig.GetItems -> foundTypes.add(it::class) + is HomeRowConfig.Favorite -> foundTypes.add(it::class) + is HomeRowConfig.Recordings -> foundTypes.add(it::class) + is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) + is HomeRowConfig.Suggestions -> foundTypes.add(it::class) + } + } + Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) + } + + @Test + fun `Print sample JSON`() { + // This just prints out the JSON of the samples so developers can review + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + prettyPrint = true + } + val string = json.encodeToString(SAMPLES) + println(string) + json.decodeFromString>(string) + } + + @Test + fun `Parse list of rows with an unknown type`() { + val service = + HomeSettingsService( + context = mockk(), + api = mockk(), + userPreferencesService = mockk(), + navDrawerItemRepository = mockk(), + latestNextUpService = mockk(), + imageUrlService = mockk(), + suggestionService = mockk(), + ) + + val str = """{ + "type": "HomePageSettings", + "version": 1, + "rows": [ + { + "type": "RecentlyAdded", + "parentId": "1dd1c2fd-2e1b-48e4-ba94-17a2350fe9cf" + }, + { + "type": "Does not exist", + "viewOptions": {} + } + ] + }""" + + val jsonElement = service.jsonParser.parseToJsonElement(str) + val settings = service.decode(jsonElement) + Assert.assertEquals(1, settings.rows.size) + } +} From 17b0eef5c5f8cb95e91f17cccd10f02af6fbd519 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:55:12 -0500 Subject: [PATCH 064/176] Add ability to order navigation drawer items (#886) ## Description Allows for reordering the items in the navigation drawer This does not change the home page row order since #803 decouples the nav drawer and home page rows. The initial order will be the "Library Order" settings on the web under Profile->Home. Making any changes locally in Wholphin will set the order. If any new libraries are added or if Seerr integration is enabled, these will added to the end of the "pinned" list. ### Related issues Closes #822 Related to #399 & #803 ### Testing Tested on emulator - Re-ordering - Granting/Revoking user permission to a new library ## Screenshots N/A, nav drawer is the same, just sorted differently ## AI or LLM usage None --- .../31.json | 642 ++++++++++++++++++ .../damontecres/wholphin/data/AppDatabase.kt | 3 +- .../wholphin/data/model/ServerPreferences.kt | 2 + .../wholphin/services/NavDrawerService.kt | 147 ++++ .../wholphin/services/hilt/AppModule.kt | 9 + .../damontecres/wholphin/ui/nav/NavDrawer.kt | 191 ++---- .../ui/preferences/NavDrawerPreference.kt | 245 +++++++ .../ui/preferences/PreferencesContent.kt | 21 +- .../ui/preferences/PreferencesViewModel.kt | 117 +++- 9 files changed, 1214 insertions(+), 163 deletions(-) create mode 100644 app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt diff --git a/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json new file mode 100644 index 00000000..b719e5f4 --- /dev/null +++ b/app/schemas/com.github.damontecres.wholphin.data.AppDatabase/31.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "c6829d764ec85321ab3be9905d6c0e3a", + "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, `order` INTEGER NOT NULL DEFAULT -1, 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 + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + } + ], + "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, 'c6829d764ec85321ab3be9905d6c0e3a')" + ] + } +} 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 a3629843..b3f33bed 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 @@ -40,7 +40,7 @@ import java.util.UUID SeerrUser::class, ], - version = 30, + version = 31, exportSchema = true, autoMigrations = [ AutoMigration(3, 4), @@ -54,6 +54,7 @@ import java.util.UUID AutoMigration(11, 12), AutoMigration(12, 20), AutoMigration(20, 30), + AutoMigration(30, 31), ], ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt index f3f52585..2b503023 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/ServerPreferences.kt @@ -1,5 +1,6 @@ package com.github.damontecres.wholphin.data.model +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -24,4 +25,5 @@ data class NavDrawerPinnedItem( val userId: Int, val itemId: String, val type: NavPinType, + @ColumnInfo(defaultValue = "-1") val order: Int, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt new file mode 100644 index 00000000..785eb676 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.asFlow +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.util.supportedCollectionTypes +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.liveTvApi +import org.jellyfin.sdk.api.client.extensions.userViewsApi +import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.UserDto +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NavDrawerService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val coroutineScope: CoroutineScope, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) { + private val _state = MutableStateFlow(NavDrawerItemState.EMPTY) + val state: StateFlow = _state + + init { + serverRepository.currentUser + .asFlow() + .combine(serverRepository.currentUserDto.asFlow()) { user, userDto -> + Pair(user, userDto) + }.onEach { (user, userDto) -> + Timber.d("User updated: user=%s, userDto=%s", user?.id, userDto?.id) + _state.update { + it.copy( + items = emptyList(), + moreItems = emptyList(), + ) + } + if (user != null && userDto != null && user.id == userDto.id) { + updateNavDrawer(user, userDto) + } + }.launchIn(coroutineScope) + seerrServerRepository.active + .onEach { discoverActive -> + _state.update { it.copy(discoverEnabled = discoverActive) } + }.launchIn(coroutineScope) + } + + suspend fun updateNavDrawer( + user: JellyfinUser, + userDto: UserDto, + ) { + val tvAccess = userDto.policy?.enableLiveTvAccess ?: false + val userViews = + api.userViewsApi + .getUserViews(userId = user.id) + .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = user.id) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } + + val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) + + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + val destination = + if (it.id in recordingFolders) { + Destination.Recordings(it.id) + } else { + BaseItem.from(it, api).destination() + } + ServerNavDrawerItem( + itemId = it.id, + name = it.name ?: it.id.toString(), + destination = destination, + type = it.collectionType ?: CollectionType.UNKNOWN, + ) + } + val allItems = builtins + libraries + + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val items = mutableListOf() + val moreItems = mutableListOf() + allItems + // Sort by order if non-default, existing items before customize will have -1 value + // New items from the server will get Int.MAX_VALUE + // Items the user doesn't have access to anymore will be skipped + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .forEach { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + if (pinned == NavPinType.PINNED) { + items.add(it) + } else { + moreItems.add(it) + } + } + + _state.update { + it.copy( + items = items, + moreItems = moreItems, + ) + } + } + } + +data class NavDrawerItemState( + val items: List, + val moreItems: List, + val discoverEnabled: Boolean, +) { + companion object { + val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) + } +} 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 f207562c..52c37413 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 @@ -44,6 +44,10 @@ annotation class StandardOkHttpClient @Retention(AnnotationRetention.BINARY) annotation class IoCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultCoroutineScope + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -177,6 +181,11 @@ object AppModule { @IoCoroutineScope fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + @Provides @Singleton fun workManager( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 6eac3206..f1c43618 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -27,8 +27,8 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember @@ -54,7 +54,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner -import androidx.lifecycle.viewModelScope import androidx.tv.material3.DrawerState import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon @@ -72,6 +71,7 @@ import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination @@ -79,23 +79,16 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse -import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType -import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -104,80 +97,17 @@ class NavDrawerViewModel @Inject constructor( private val api: ApiClient, + private val navDrawerService: NavDrawerService, private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { - val moreLibraries = MutableLiveData>(null) - val libraries = MutableLiveData>(listOf()) + val state = navDrawerService.state val selectedIndex = MutableLiveData(-1) - val showMore = MutableLiveData(false) - - init { - seerrServerRepository.active - .onEach { - init() - }.launchIn(viewModelScope) - } - - fun init() { - viewModelScope.launchIO { - val all = navDrawerItemRepository.getNavDrawerItems() - val libraries = navDrawerItemRepository.getFilteredNavDrawerItems(all) - val moreLibraries = all.toMutableList().apply { removeAll(libraries) } - - withContext(Dispatchers.Main) { - this@NavDrawerViewModel.moreLibraries.value = moreLibraries - this@NavDrawerViewModel.libraries.value = libraries - } - val asDestinations = - ( - libraries + - listOf( - NavDrawerItem.More, - NavDrawerItem.Discover, - ) + moreLibraries - ).map { - if (it is ServerNavDrawerItem) { - it.destination - } else if (it is NavDrawerItem.Favorites) { - Destination.Favorites - } else if (it is NavDrawerItem.Discover) { - Destination.Discover - } else { - null - } - } - - val backstack = navigationManager.backStack.toList().reversed() - for (i in 0..= 0) { - idx - } else { - null - } - } - Timber.v("Found $index => $key") - if (index != null) { - selectedIndex.setValueOnMain(index) - break - } - } - } - } - } + val moreExpanded = MutableLiveData(false) fun onClickDrawerItem( index: Int, @@ -193,7 +123,7 @@ class NavDrawerViewModel } NavDrawerItem.More -> { - setShowMore(!showMore.value!!) + setShowMore(!moreExpanded.value!!) } NavDrawerItem.Discover -> { @@ -215,7 +145,7 @@ class NavDrawerViewModel } fun setShowMore(value: Boolean) { - showMore.value = value + moreExpanded.value = value } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) @@ -289,15 +219,12 @@ fun NavDrawer( drawerState.setValue(DrawerValue.Open) focusRequester.requestFocus() } - val moreLibraries by viewModel.moreLibraries.observeAsState(listOf()) - val libraries by viewModel.libraries.observeAsState(listOf()) - LaunchedEffect(Unit) { viewModel.init() } - - val showMore by viewModel.showMore.observeAsState(false) - // A negative index is a built in page, >=0 is a library + val state by viewModel.state.collectAsState() + val moreExpanded by viewModel.moreExpanded.observeAsState(false) + // A negative index is a built-in page, >=0 is a library val selectedIndex by viewModel.selectedIndex.observeAsState(-1) - BackHandler(enabled = showMore && drawerState.currentValue == DrawerValue.Open) { + BackHandler(enabled = moreExpanded && drawerState.currentValue == DrawerValue.Open) { viewModel.setShowMore(false) } @@ -412,51 +339,83 @@ fun NavDrawer( ), ) } - itemsIndexed(libraries) { index, it -> - val interactionSource = remember { MutableInteractionSource() } - NavItem( - library = it, - selected = selectedIndex == index, - moreExpanded = showMore, - drawerOpen = isOpen, - interactionSource = interactionSource, - onClick = { - viewModel.onClickDrawerItem(index, it) - }, - modifier = - Modifier - .ifElse( - selectedIndex == index, - Modifier.focusRequester(focusRequester), - ), - ) - } - if (showMore) { - itemsIndexed(moreLibraries) { index, it -> - val adjustedIndex = (index + libraries.size + 1) + itemsIndexed(state.items) { index, it -> + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { val interactionSource = remember { MutableInteractionSource() } NavItem( library = it, - selected = selectedIndex == adjustedIndex, - moreExpanded = showMore, + selected = selectedIndex == index, + moreExpanded = moreExpanded, drawerOpen = isOpen, - onClick = { viewModel.onClickDrawerItem(adjustedIndex, it) }, - containerColor = - if (isOpen) { - MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - } else { - Color.Unspecified - }, interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, it) + }, modifier = Modifier .ifElse( - selectedIndex == adjustedIndex, + selectedIndex == index, Modifier.focusRequester(focusRequester), ), ) } } + if (state.moreItems.isNotEmpty()) { + item { + val index = state.items.size + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = NavDrawerItem.More, + selected = selectedIndex == index, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + interactionSource = interactionSource, + onClick = { + viewModel.onClickDrawerItem(index, NavDrawerItem.More) + }, + modifier = + Modifier + .ifElse( + selectedIndex == index, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + if (moreExpanded) { + itemsIndexed(state.moreItems) { index, it -> + val adjustedIndex = + remember(state) { (index + state.items.size + 1) } + if (it !is NavDrawerItem.Discover || state.discoverEnabled) { + val interactionSource = remember { MutableInteractionSource() } + NavItem( + library = it, + selected = selectedIndex == adjustedIndex, + moreExpanded = moreExpanded, + drawerOpen = isOpen, + onClick = { + viewModel.onClickDrawerItem( + adjustedIndex, + it, + ) + }, + containerColor = + if (isOpen) { + MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) + } else { + Color.Unspecified + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + selectedIndex == adjustedIndex, + Modifier.focusRequester(focusRequester), + ), + ) + } + } + } item { val interactionSource = remember { MutableInteractionSource() } IconNavItem( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt new file mode 100644 index 00000000..0504d988 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -0,0 +1,245 @@ +package com.github.damontecres.wholphin.ui.preferences + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.tv.material3.ListItem +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Switch +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.PreviewTvSpec +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.nav.NavDrawerItem +import com.github.damontecres.wholphin.ui.theme.WholphinTheme + +data class NavDrawerPin( + val id: String, + val title: String, + val pinned: Boolean, + val item: NavDrawerItem, +) { + companion object { + fun create( + context: Context, + items: Map, + ) { + items.map { (item, pinned) -> + NavDrawerPin(item.id, item.name(context), pinned, item) + } + } + } +} + +enum class MoveDirection { + UP, + DOWN, +} + +private fun List.move( + direction: MoveDirection, + index: Int, +): List = + toMutableList().apply { + if (direction == MoveDirection.DOWN) { + val down = this[index] + val up = this[index + 1] + set(index, up) + set(index + 1, down) + } else { + val up = this[index] + val down = this[index - 1] + set(index - 1, up) + set(index, down) + } + } + +@Composable +fun NavDrawerPreference( + title: String, + summary: String?, + items: List, + onSave: (List) -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + var showDialog by remember { mutableStateOf(false) } + ClickPreference( + title = title, + summary = summary, + onClick = { showDialog = true }, + interactionSource = interactionSource, + modifier = modifier, + ) + if (showDialog) { + NavDrawerPreferenceDialog( + items = items, + onDismissRequest = { showDialog = false }, + onClick = { index -> + val newItems = + items.toMutableList().apply { + set(index, items[index].let { it.copy(pinned = !it.pinned) }) + } + onSave.invoke(newItems) + }, + onMoveUp = { index -> + onSave(items.move(MoveDirection.UP, index)) + }, + onMoveDown = { index -> + onSave(items.move(MoveDirection.DOWN, index)) + }, + ) + } +} + +@Composable +fun NavDrawerPreferenceDialog( + items: List, + onDismissRequest: () -> Unit, + onClick: (Int) -> Unit, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = stringResource(R.string.nav_drawer_pins), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + NavDrawerPreferenceListItem( + title = item.title, + pinned = item.pinned, + moveUpAllowed = index > 0, + moveDownAllowed = index < items.lastIndex, + onClick = { onClick.invoke(index) }, + onMoveUp = { onMoveUp.invoke(index) }, + onMoveDown = { onMoveDown.invoke(index) }, + modifier = Modifier, + ) + } + } + } + } +} + +@Composable +fun NavDrawerPreferenceListItem( + title: String, + pinned: Boolean, + moveUpAllowed: Boolean, + moveDownAllowed: Boolean, + onClick: () -> Unit, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 40.dp, max = 88.dp), + ) { + ListItem( + selected = false, + headlineContent = { + Text( + text = title, + ) + }, + trailingContent = { + Switch( + checked = pinned, + onCheckedChange = { + onClick.invoke() + }, + ) + }, + onClick = onClick, + modifier = Modifier.weight(1f), + ) + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.wrapContentWidth(), + ) { + MoveButton(R.string.fa_caret_up, moveUpAllowed, onMoveUp) + MoveButton(R.string.fa_caret_down, moveDownAllowed, onMoveDown) + } + } + } +} + +@Composable +private fun MoveButton( + @StringRes icon: Int, + enabled: Boolean, + onClick: () -> Unit, +) = Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(32.dp), +) { + Text( + text = stringResource(icon), + fontSize = 16.sp, + fontFamily = FontAwesome, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) +} + +@PreviewTvSpec +@Composable +fun NavDrawerPreferenceListItemPreview() { + WholphinTheme { + NavDrawerPreferenceListItem( + title = "Movies", + pinned = true, + moveUpAllowed = true, + moveDownAllowed = true, + onClick = {}, + onMoveUp = {}, + onMoveDown = { }, + modifier = Modifier.width(360.dp), + ) + } +} 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 29fa873e..8db91b08 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 @@ -89,7 +89,7 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.observeAsState(mapOf()) + val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } @@ -335,21 +335,16 @@ fun PreferencesContent( } AppPreference.UserPinnedNavDrawerItems -> { - val selectedItems = - navDrawerPins.keys.mapNotNull { - if (navDrawerPins[it] ?: false) it else null - } - MultiChoicePreference( + NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - possibleValues = navDrawerPins.keys, - selectedValues = selectedItems.toSet(), - onValueChange = { newSelectedItems -> - viewModel.updatePins(newSelectedItems) + items = navDrawerPins, + onSave = { + viewModel.updatePins(it) }, - ) { - Text(it.name(context)) - } + modifier = Modifier, + interactionSource = interactionSource, + ) } AppPreference.SendAppLogs -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index dd7a8e79..4113dbea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -2,14 +2,12 @@ package com.github.damontecres.wholphin.ui.preferences import android.content.Context import androidx.datastore.core.DataStore -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.isPinned import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem import com.github.damontecres.wholphin.data.model.NavPinType @@ -17,23 +15,28 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -48,6 +51,7 @@ class PreferencesViewModel private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, @@ -55,7 +59,46 @@ class PreferencesViewModel ) : ViewModel(), RememberTabManager by rememberTabManager { private lateinit var allNavDrawerItems: List - val navDrawerPins = MutableLiveData>(mapOf()) +// val navDrawerPins = MutableLiveData>(emptyList()) + + val navDrawerPins = + navDrawerService.state + .combine( + serverRepository.currentUser.asFlow(), + ) { state, user -> + Pair(state, user) + }.combine(seerrServerRepository.active) { (state, user), seerr -> + Triple(state, user, seerr) + }.map { (state, user, seerr) -> + withContext(Dispatchers.IO) { + val navDrawerPins = + serverPreferencesDao + .getNavDrawerPinnedItems(user!!) + .associateBy { it.itemId } + + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + + pins + } + } val currentUser get() = serverRepository.currentUser @@ -70,42 +113,50 @@ class PreferencesViewModel init { viewModelScope.launchIO { serverRepository.currentUser.value?.let { user -> - allNavDrawerItems = navDrawerItemRepository.getNavDrawerItems() - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) +// fetchNavDrawerPins(user) } } } - fun updatePins(newSelectedItems: List) { + private suspend fun fetchNavDrawerPins(user: JellyfinUser) { + navDrawerService.state.map { + val navDrawerPins = + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + + val allItems = navDrawerService.state.first().let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } + .map { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it) + } + pins + } + } + + fun updatePins(items: List) { viewModelScope.launchIO(ExceptionHandler(true)) { serverRepository.currentUser.value?.let { user -> - val disabledItems = - mutableListOf().apply { - addAll(allNavDrawerItems) - removeAll(newSelectedItems) + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + Timber.v("Updating pins") + val toSave = + items.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") } - val enabledItems = newSelectedItems.toSet() - val toSave = - disabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.UNPINNED, - ) - } + - enabledItems.map { - NavDrawerPinnedItem( - user.rowId, - it.id, - NavPinType.PINNED, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - val pins = serverPreferencesDao.getNavDrawerPinnedItems(user) - val navDrawerPins = allNavDrawerItems.associateWith { pins.isPinned(it.id) } - this@PreferencesViewModel.navDrawerPins.setValueOnMain(navDrawerPins) + } } } } From 7fcd66f4073642f83f9f848ea50ac8c44630de97 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 12 Feb 2026 21:19:33 -0500 Subject: [PATCH 065/176] Integrate nav drawer settings for default home page (#888) ## Description Customizing the home page (#803) and nav drawer (#886) were developed independently, so this PR just cleans up somethings so that those two changes work together. This PR restores the default home page when there is no customization to be the same as before: continue watching/next up row(s) followed by the order of the nav drawer libraries' recently added rows. Finally gets rid of `NavDrawerIteRepository` and related functions which all had weird, hard to follow code. ### Related issues Related to #803 & #886 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/NavDrawerItemRepository.kt | 97 ------------------- .../wholphin/services/HomeSettingsService.kt | 34 +++---- .../wholphin/services/NavDrawerService.kt | 25 +++++ .../wholphin/ui/main/HomeViewModel.kt | 17 +--- .../ui/main/settings/HomeSettingsViewModel.kt | 20 ++-- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 10 +- .../ui/preferences/PreferencesViewModel.kt | 5 - .../wholphin/test/TestHomeRowSamples.kt | 3 +- 8 files changed, 58 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt deleted file mode 100644 index 2312c62c..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/NavDrawerItemRepository.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.github.damontecres.wholphin.data - -import android.content.Context -import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType -import com.github.damontecres.wholphin.services.SeerrServerRepository -import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem -import com.github.damontecres.wholphin.util.supportedCollectionTypes -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.flow.first -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.api.client.extensions.liveTvApi -import org.jellyfin.sdk.api.client.extensions.userViewsApi -import org.jellyfin.sdk.model.api.CollectionType -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NavDrawerItemRepository - @Inject - constructor( - @param:ApplicationContext private val context: Context, - private val api: ApiClient, - private val serverRepository: ServerRepository, - private val serverPreferencesDao: ServerPreferencesDao, - private val seerrServerRepository: SeerrServerRepository, - ) { - suspend fun getNavDrawerItems(): List { - val user = serverRepository.currentUser.value - val tvAccess = - serverRepository.currentUserDto.value - ?.policy - ?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user?.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user?.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - - val builtins = - if (seerrServerRepository.active.first()) { - listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - } else { - listOf(NavDrawerItem.Favorites) - } - - val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } - .map { - val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) - } else { - BaseItem.from(it, api).destination() - } - ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), - destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, - ) - } - return builtins + libraries - } - - suspend fun getFilteredNavDrawerItems(items: List): List { - val user = serverRepository.currentUser.value - val navDrawerPins = - user - ?.let { - serverPreferencesDao.getNavDrawerPinnedItems(it) - }.orEmpty() - val filtered = items.filter { navDrawerPins.isPinned(it.id) } - if (items.size != filtered.size) { - // Some were filtered out, check if should include More - if (navDrawerPins.isPinned(NavDrawerItem.More.id)) { - return filtered + listOf(NavDrawerItem.More) - } - } - return filtered - } - } - -fun List.isPinned(id: String) = (firstOrNull { it.itemId == id }?.type ?: NavPinType.PINNED) == NavPinType.PINNED diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index a868692a..083d461d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -2,7 +2,7 @@ package com.github.damontecres.wholphin.services import android.content.Context import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository +import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig @@ -12,8 +12,8 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -64,8 +64,9 @@ class HomeSettingsService constructor( @param:ApplicationContext private val context: Context, private val api: ApiClient, + private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val latestNextUpService: LatestNextUpService, private val imageUrlService: ImageUrlService, private val suggestionService: SuggestionService, @@ -233,7 +234,7 @@ class HomeSettingsService } HomePageResolvedSettings(resolvedRows) } else { - createDefault() + createDefault(userId) } currentSettings.update { resolvedSettings } @@ -251,25 +252,24 @@ class HomeSettingsService /** * Create a default [HomePageResolvedSettings] using the available libraries */ - suspend fun createDefault(): HomePageResolvedSettings { + suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") - val navDrawerItems = navDrawerItemRepository.getNavDrawerItems() + val user = serverRepository.currentUser.value?.takeIf { it.id == userId } val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } + if (user != null) { + navDrawerService.getFilteredUserLibraries(user) + } else { + navDrawerService.getAllUserLibraries(userId) + } + val prefs = userPreferencesService.getCurrent().appPreferences.homePagePreferences + val includedIds = - navDrawerItemRepository - .getFilteredNavDrawerItems(navDrawerItems) - .filter { it is ServerNavDrawerItem } + libraries .mapIndexed { index, it -> - val parentId = (it as ServerNavDrawerItem).itemId - val name = libraries.firstOrNull { it.itemId == parentId }?.name + val parentId = it.itemId + val name = it.name.takeIf { it.isNotNullOrBlank() } val title = name?.let { context.getString(R.string.recently_added_in, it) } ?: context.getString(R.string.recently_added) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 785eb676..fbce042c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem @@ -26,6 +27,7 @@ import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.UserDto import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -66,6 +68,29 @@ class NavDrawerService }.launchIn(coroutineScope) } + suspend fun getAllUserLibraries(userId: UUID): List { + val userViews = + api.userViewsApi + .getUserViews(userId = userId) + .content.items + val libraries = + userViews + .filter { it.collectionType in supportedCollectionTypes } + .map { Library(it.id, it.name ?: "", it.collectionType ?: CollectionType.UNKNOWN) } + return libraries + } + + suspend fun getFilteredUserLibraries(user: JellyfinUser): List { + val pins = + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + val libraries = + getAllUserLibraries(user.id) + .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } + return libraries + } + suspend fun updateNavDrawer( user: JellyfinUser, userDto: UserDto, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 7ee22902..1e72830f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.main import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig @@ -13,11 +12,10 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService import com.github.damontecres.wholphin.services.MediaReportService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.main.settings.Library -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -46,8 +44,8 @@ class HomeViewModel @param:ApplicationContext private val context: Context, val navigationManager: NavigationManager, val serverRepository: ServerRepository, - val navDrawerItemRepository: NavDrawerItemRepository, val mediaReportService: MediaReportService, + private val navDrawerService: NavDrawerService, private val homeSettingsService: HomeSettingsService, private val favoriteWatchManager: FavoriteWatchManager, private val datePlayedService: DatePlayedService, @@ -69,17 +67,8 @@ class HomeViewModel val preferences = userPreferencesService.getCurrent() val prefs = preferences.appPreferences.homePagePreferences - val navDrawerItems = - navDrawerItemRepository - .getNavDrawerItems() - val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } serverRepository.currentUserDto.value?.let { userDto -> + val libraries = navDrawerService.getAllUserLibraries(userDto.id) val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } val state = state.value diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 3a5f088a..53c457ff 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -7,7 +7,6 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings @@ -26,12 +25,12 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeRowConfigDisplay import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -68,7 +67,7 @@ class HomeSettingsViewModel private val homeSettingsService: HomeSettingsService, private val serverRepository: ServerRepository, private val userPreferencesService: UserPreferencesService, - private val navDrawerItemRepository: NavDrawerItemRepository, + private val navDrawerService: NavDrawerService, private val backdropService: BackdropService, private val seerrServerRepository: SeerrServerRepository, val preferencesDataStore: DataStore, @@ -84,16 +83,8 @@ class HomeSettingsViewModel init { addCloseable { saveToLocal() } viewModelScope.launchIO { - val navDrawerItems = - navDrawerItemRepository - .getNavDrawerItems() - val libraries = - navDrawerItems - .filter { it is ServerNavDrawerItem } - .map { - it as ServerNavDrawerItem - Library(it.itemId, it.name, it.type) - } + val userId = serverRepository.currentUser.value?.id ?: return@launchIO + val libraries = navDrawerService.getAllUserLibraries(userId) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } Timber.v("currentSettings=%s", currentSettings) @@ -525,8 +516,9 @@ class HomeSettingsViewModel fun resetToDefault() { viewModelScope.launchIO { + val userId = serverRepository.currentUser.value?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } - val result = homeSettingsService.createDefault() + val result = homeSettingsService.createDefault(userId) _state.update { it.copy(rows = result.rows) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index f1c43618..3527a95e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -65,7 +65,6 @@ import androidx.tv.material3.ProvideTextStyle import androidx.tv.material3.Text import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppThemeColors @@ -73,7 +72,6 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager -import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome @@ -98,11 +96,9 @@ class NavDrawerViewModel constructor( private val api: ApiClient, private val navDrawerService: NavDrawerService, - private val navDrawerItemRepository: NavDrawerItemRepository, val navigationManager: NavigationManager, val setupNavigationManager: SetupNavigationManager, val backdropService: BackdropService, - private val seerrServerRepository: SeerrServerRepository, ) : ViewModel() { val state = navDrawerService.state @@ -184,9 +180,13 @@ data class ServerNavDrawerItem( val destination: Destination, val type: CollectionType, ) : NavDrawerItem { - override val id: String = "s_" + itemId.toServerString() + override val id: String = getId(itemId) override fun name(context: Context): String = name + + companion object { + fun getId(itemId: UUID) = "s_" + itemId.toServerString() + } } /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 4113dbea..97223b89 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -5,7 +5,6 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.NavDrawerItemRepository import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -50,7 +49,6 @@ class PreferencesViewModel val backdropService: BackdropService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerService: NavDrawerService, private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, @@ -58,9 +56,6 @@ class PreferencesViewModel private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { - private lateinit var allNavDrawerItems: List -// val navDrawerPins = MutableLiveData>(emptyList()) - val navDrawerPins = navDrawerService.state .combine( diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 3aedb3ba..675b8b26 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -121,8 +121,9 @@ class TestHomeRowSamples { HomeSettingsService( context = mockk(), api = mockk(), + serverRepository = mockk(), userPreferencesService = mockk(), - navDrawerItemRepository = mockk(), + navDrawerService = mockk(), latestNextUpService = mockk(), imageUrlService = mockk(), suggestionService = mockk(), From 91900af7a925f8bfe08173d1f1cb6c955530d833 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:41:47 -0500 Subject: [PATCH 066/176] Improved home page support for Live TV & Recordings (#890) ## Description Improves home page customization for Live TV & Recordings - Add row for live tv channels - Add row for "on now" programs - Clicking on a TV channel or TV program starts playback - Better name for "Recently recorded" instead of "Recently added in Recordings" Also fixes a back stack issue after adding rows ### Related issues Follow up from #803 ### Testing Emulator ## Screenshots image ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 19 +++++ .../wholphin/data/model/HomeRowConfig.kt | 21 ++++- .../wholphin/preferences/AppPreference.kt | 1 + .../wholphin/services/HomeSettingsService.kt | 81 ++++++++++++++----- .../wholphin/services/NavDrawerService.kt | 74 ++++++++++------- .../damontecres/wholphin/ui/UiConstants.kt | 2 +- .../wholphin/ui/main/HomeViewModel.kt | 4 +- .../main/settings/HomeLibraryRowTypeList.kt | 41 ++++++++-- .../ui/main/settings/HomeSettingsPage.kt | 4 +- .../ui/main/settings/HomeSettingsViewModel.kt | 41 +++++++++- .../wholphin/ui/nav/DestinationContent.kt | 2 +- app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestHomeRowSamples.kt | 2 + 13 files changed, 229 insertions(+), 64 deletions(-) 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 3030b7d0..172c6ef5 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 @@ -187,6 +187,25 @@ data class BaseItem( ) } + BaseItemKind.TV_CHANNEL -> { + Destination.Playback( + itemId = id, + positionMs = 0L, + ) + } + + BaseItemKind.PROGRAM -> { + val channelId = data.channelId + if (channelId != null) { + Destination.Playback( + itemId = channelId, + positionMs = 0L, + ) + } else { + Destination.MediaItem(this) + } + } + else -> { Destination.MediaItem(this) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 7d521ce4..c11ad321 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -111,7 +111,7 @@ sealed interface HomeRowConfig { } /** - * + * Currently recording */ @Serializable @SerialName("Recordings") @@ -122,7 +122,7 @@ sealed interface HomeRowConfig { } /** - * + * Programs on now/recommended */ @Serializable @SerialName("TvPrograms") @@ -132,6 +132,17 @@ sealed interface HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) } + /** + * Live TV channels + */ + @Serializable + @SerialName("TvChannels") + data class TvChannels( + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) + } + /** * Fetch suggestions from [com.github.damontecres.wholphin.services.SuggestionService] for the given parent ID */ @@ -217,5 +228,11 @@ data class HomeRowViewOptions( heightDp = Cards.HEIGHT_EPISODE, aspectRatio = AspectRatio.WIDE, ) + + val channelsDefault = + HomeRowViewOptions( + heightDp = 96, + aspectRatio = AspectRatio.WIDE, + ) } } 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 7eaa2ffe..709abd6c 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 @@ -660,6 +660,7 @@ sealed interface AppPreference { AppDestinationPreference( title = R.string.customize_home, destination = Destination.HomeSettings, + summary = R.string.customize_home_summary, ) val SendCrashReports = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 083d461d..99ced240 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -12,13 +12,14 @@ import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap -import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState +import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success import com.github.damontecres.wholphin.util.supportedHomeCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext @@ -43,6 +44,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder @@ -255,11 +257,12 @@ class HomeSettingsService suspend fun createDefault(userId: UUID): HomePageResolvedSettings { Timber.v("Creating default settings") val user = serverRepository.currentUser.value?.takeIf { it.id == userId } + val userDto = serverRepository.currentUserDto.value?.takeIf { it.id == userId } val libraries = if (user != null) { - navDrawerService.getFilteredUserLibraries(user) + navDrawerService.getFilteredUserLibraries(user, userDto?.tvAccess ?: false) } else { - navDrawerService.getAllUserLibraries(userId) + navDrawerService.getAllUserLibraries(userId, userDto?.tvAccess ?: false) } val prefs = @@ -269,18 +272,23 @@ class HomeSettingsService libraries .mapIndexed { index, it -> val parentId = it.itemId - val name = it.name.takeIf { it.isNotNullOrBlank() } - val title = - name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) - HomeRowConfigDisplay( - id = index, - title = title, - config = HomeRowConfig.RecentlyAdded(parentId), - ) + val title = getRecentlyAddedTitle(context, it) + if (it.collectionType == CollectionType.LIVETV) { + HomeRowConfigDisplay( + id = index, + title = context.getString(R.string.live_tv), + config = HomeRowConfig.TvPrograms(), + ) + } else { + HomeRowConfigDisplay( + id = index, + title = title, + config = HomeRowConfig.RecentlyAdded(parentId), + ) + } } val continueWatchingRows = - if (prefs.combineContinueNext) { // TODO + if (prefs.combineContinueNext) { listOf( HomeRowConfigDisplay( id = includedIds.size + 1, @@ -363,7 +371,7 @@ class HomeSettingsService } HomeSectionType.LIVE_TV -> { - if (userDto.policy?.enableLiveTvAccess == true) { + if (userDto.tvAccess) { HomeRowConfigDisplay( id = id++, title = context.getString(R.string.live_tv), @@ -523,6 +531,14 @@ class HomeSettingsService ) } + is HomeRowConfig.TvChannels -> { + HomeRowConfigDisplay( + id = id, + title = context.getString(R.string.channels), + config, + ) + } + is HomeRowConfig.Suggestions -> { val name = api.userLibraryApi @@ -654,13 +670,10 @@ class HomeSettingsService } is HomeRowConfig.RecentlyAdded -> { - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name - val title = - name?.let { context.getString(R.string.recently_added_in, it) } - ?: context.getString(R.string.recently_added) + val title = getRecentlyAddedTitle(context, library) val request = GetLatestMediaRequest( fields = SlimItemFields, @@ -862,6 +875,23 @@ class HomeSettingsService } } + is HomeRowConfig.TvChannels -> { + api.liveTvApi + .getLiveTvChannels( + userId = userDto.id, + fields = DefaultItemFields, + limit = limit, + enableImages = true, + ).toBaseItems(api, row.viewOptions.useSeries) + .let { + Success( + context.getString(R.string.channels), + it, + row.viewOptions, + ) + } + } + is HomeRowConfig.Suggestions -> { val library = api.userLibraryApi @@ -888,7 +918,7 @@ class HomeSettingsService row.viewOptions, ) } else { - HomeRowLoadingState.Error( + Error( title, message = "Unsupported type ${library.collectionType}", ) @@ -949,3 +979,14 @@ class UnsupportedHomeSettingsVersionException( val unsupportedVersion: Int?, val maxSupportedVersion: Int = SUPPORTED_HOME_PAGE_SETTINGS_VERSION, ) : Exception("Unsupported version $unsupportedVersion, max supported is $maxSupportedVersion") + +fun getRecentlyAddedTitle( + context: Context, + library: Library?, +): String = + if (library?.isRecordingFolder == true) { + context.getString(R.string.recently_recorded) + } else { + library?.name?.let { context.getString(R.string.recently_added_in, it) } + ?: context.getString(R.string.recently_added) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index fbce042c..adb21ff7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.lifecycle.asFlow import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository -import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope @@ -68,25 +67,49 @@ class NavDrawerService }.launchIn(coroutineScope) } - suspend fun getAllUserLibraries(userId: UUID): List { + suspend fun getAllUserLibraries( + userId: UUID, + tvAccess: Boolean, + ): List { val userViews = api.userViewsApi .getUserViews(userId = userId) .content.items + val recordingFolders = + if (tvAccess) { + api.liveTvApi + .getRecordingFolders(userId = userId) + .content.items + .map { it.id } + .toSet() + } else { + setOf() + } val libraries = userViews - .filter { it.collectionType in supportedCollectionTypes } - .map { Library(it.id, it.name ?: "", it.collectionType ?: CollectionType.UNKNOWN) } + .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + .map { + Library( + itemId = it.id, + name = it.name ?: "", + type = it.type, + collectionType = it.collectionType ?: CollectionType.UNKNOWN, + isRecordingFolder = it.id in recordingFolders, + ) + } return libraries } - suspend fun getFilteredUserLibraries(user: JellyfinUser): List { + suspend fun getFilteredUserLibraries( + user: JellyfinUser, + tvAccess: Boolean, + ): List { val pins = serverPreferencesDao .getNavDrawerPinnedItems(user) .associateBy { it.itemId } val libraries = - getAllUserLibraries(user.id) + getAllUserLibraries(user.id, tvAccess) .filterNot { pins[ServerNavDrawerItem.getId(it.itemId)]?.type == NavPinType.UNPINNED } return libraries } @@ -95,39 +118,26 @@ class NavDrawerService user: JellyfinUser, userDto: UserDto, ) { - val tvAccess = userDto.policy?.enableLiveTvAccess ?: false - val userViews = - api.userViewsApi - .getUserViews(userId = user.id) - .content.items - val recordingFolders = - if (tvAccess) { - api.liveTvApi - .getRecordingFolders(userId = user.id) - .content.items - .map { it.id } - .toSet() - } else { - setOf() - } - val builtins = listOf(NavDrawerItem.Favorites, NavDrawerItem.Discover) - + val allLibraries = getAllUserLibraries(user.id, userDto.tvAccess) val libraries = - userViews - .filter { it.collectionType in supportedCollectionTypes || it.id in recordingFolders } + allLibraries .map { val destination = - if (it.id in recordingFolders) { - Destination.Recordings(it.id) + if (it.isRecordingFolder) { + Destination.Recordings(it.itemId) } else { - BaseItem.from(it, api).destination() + Destination.MediaItem( + it.itemId, + it.type, + it.collectionType, + ) } ServerNavDrawerItem( - itemId = it.id, - name = it.name ?: it.id.toString(), + itemId = it.itemId, + name = it.name, destination = destination, - type = it.collectionType ?: CollectionType.UNKNOWN, + type = it.collectionType, ) } val allItems = builtins + libraries @@ -170,3 +180,5 @@ data class NavDrawerItemState( val EMPTY = NavDrawerItemState(emptyList(), emptyList(), false) } } + +val UserDto.tvAccess: Boolean get() = policy?.enableLiveTvAccess == true 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 0db41202..2d9b6d5e 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 @@ -84,7 +84,7 @@ val PhotoItemFields = object Cards { const val HEIGHT_2X3_DP = 172 val height2x3 = HEIGHT_2X3_DP.dp - val HEIGHT_EPISODE = (HEIGHT_2X3_DP * .75f).toInt().let { it - it % 4 } + const val HEIGHT_EPISODE = 128 val heightEpisode = HEIGHT_EPISODE.dp val playedPercentHeight = 6.dp val serverUserCircle = height2x3 * .75f diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 1e72830f..58296848 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -68,7 +69,8 @@ class HomeViewModel val prefs = preferences.appPreferences.homePagePreferences serverRepository.currentUserDto.value?.let { userDto -> - val libraries = navDrawerService.getAllUserLibraries(userDto.id) + val libraries = + navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val settings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } val state = state.value diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index c680e077..87ce4d39 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.services.SuggestionsWorker import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.tryRequestFocus +import org.jellyfin.sdk.model.api.CollectionType @Composable fun HomeLibraryRowTypeList( @@ -63,11 +64,38 @@ fun HomeLibraryRowTypeList( } fun getSupportedRowTypes(library: Library): List { - val itemKind = SuggestionsWorker.getTypeForCollection(library.collectionType) - return if (itemKind != null) { - LibraryRowType.entries - } else { - LibraryRowType.entries.toMutableList().apply { remove(LibraryRowType.SUGGESTIONS) } + val supportsSuggestions = SuggestionsWorker.getTypeForCollection(library.collectionType) != null + return when { + library.isRecordingFolder -> { + listOf( + LibraryRowType.RECENTLY_RECORDED, + LibraryRowType.GENRES, + ) + } + + library.collectionType == CollectionType.LIVETV -> { + listOf( + LibraryRowType.TV_CHANNELS, + LibraryRowType.TV_PROGRAMS, + ) + } + + supportsSuggestions -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + ) + } + + else -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + ) + } } } @@ -78,4 +106,7 @@ enum class LibraryRowType( RECENTLY_RELEASED(R.string.recently_released), SUGGESTIONS(R.string.suggestions), GENRES(R.string.genres), + TV_CHANNELS(R.string.channels), + TV_PROGRAMS(R.string.live_tv), + RECENTLY_RECORDED(R.string.recently_recorded), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 616eff13..17a02b59 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -61,7 +61,9 @@ fun HomeSettingsPage( // Adds a row, waits until its done loading, then scrolls to the new row fun addRow(func: () -> Job) { scope.launch(ExceptionHandler(autoToast = true)) { - backStack.add(HomeSettingsDestination.RowList) + while (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } func.invoke().join() listState.animateScrollToItem(state.rows.lastIndex) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 53c457ff..11fd058c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.UnsupportedHomeSettingsVersionException import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.tvAccess import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.HomeRowLoadingState @@ -83,8 +84,8 @@ class HomeSettingsViewModel init { addCloseable { saveToLocal() } viewModelScope.launchIO { - val userId = serverRepository.currentUser.value?.id ?: return@launchIO - val libraries = navDrawerService.getAllUserLibraries(userId) + val userDto = serverRepository.currentUserDto.value ?: return@launchIO + val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } Timber.v("currentSettings=%s", currentSettings) @@ -292,6 +293,36 @@ class HomeSettingsViewModel config = Suggestions(library.itemId), ) } + + LibraryRowType.TV_CHANNELS -> { + val title = context.getString(R.string.channels) + HomeRowConfigDisplay( + id = id, + title = title, + config = + HomeRowConfig.TvChannels( + viewOptions = HomeRowViewOptions.channelsDefault, + ), + ) + } + + LibraryRowType.TV_PROGRAMS -> { + val title = context.getString(R.string.watch_live) + HomeRowConfigDisplay( + id = id, + title = title, + config = HomeRowConfig.TvPrograms(), + ) + } + + LibraryRowType.RECENTLY_RECORDED -> { + val title = context.getString(R.string.recently_recorded) + HomeRowConfigDisplay( + id = id, + title = title, + config = RecentlyAdded(library.itemId), + ) + } } updateState { it.copy( @@ -614,6 +645,10 @@ class HomeSettingsViewModel is HomeRowConfig.TvPrograms -> { it.config.updateViewOptions(preset.tvLibrary) } + + is HomeRowConfig.TvChannels -> { + it.config + } } it.copy(config = newConfig) } @@ -658,5 +693,7 @@ data class HomePageSettingsState( data class Library( @Serializable(UUIDSerializer::class) val itemId: UUID, val name: String, + val type: BaseItemKind, val collectionType: CollectionType, + val isRecordingFolder: Boolean, ) 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 a16e909a..eccfd6de 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 @@ -214,7 +214,7 @@ fun DestinationContent( else -> { Timber.w("Unsupported item type: ${destination.type}") - Text("Unsupported item type: ${destination.type}") + Text("Unsupported item type: ${destination.type}", modifier) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8533441..3d7b1b5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -520,6 +520,7 @@ Settings saved Display presets Built-in presets to quickly style all rows + Choose rows and images on the home page Disabled diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 675b8b26..84aa72b6 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -74,6 +74,7 @@ class TestHomeRowSamples { HomeRowConfig.Favorite(kind = BaseItemKind.SERIES), HomeRowConfig.Recordings(), HomeRowConfig.TvPrograms(), + HomeRowConfig.TvChannels(), HomeRowConfig.Suggestions(parentId = UUID.randomUUID()), ) } @@ -96,6 +97,7 @@ class TestHomeRowSamples { is HomeRowConfig.Recordings -> foundTypes.add(it::class) is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) is HomeRowConfig.Suggestions -> foundTypes.add(it::class) + is HomeRowConfig.TvChannels -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size) From aa69646b2db96f4dcad2c16e1a3bf28b5d2aab62 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:49:19 -0500 Subject: [PATCH 067/176] Upgrade AGP to 9.x (#892) ## Description This PR has no user facing changes. Upgrade AGP to 9.x and Gradle to 9.x. Also updates some dependencies as well. Note: still using deprecated `android.newDsl=false` because the protobuf plugin is not yet compatible: https://github.com/google/protobuf-gradle-plugin/issues/793. ### Related issues N/A ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 4 ++-- build.gradle.kts | 1 - gradle.properties | 10 ++++++++++ gradle/libs.versions.toml | 16 ++++++---------- gradle/wrapper/gradle-wrapper.properties | 2 +- 5 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 29c36ac2..5ab4a9cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,7 +7,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) alias(libs.plugins.hilt) @@ -70,6 +69,7 @@ android { } kotlin { compilerOptions { + languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = JvmTarget.JVM_11 javaParameters = true } @@ -150,7 +150,7 @@ android { sourceSets { getByName("main") { - kotlin.srcDirs("$buildDir/generated/seerr_api/src/main/kotlin") + kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } } diff --git a/build.gradle.kts b/build.gradle.kts index f2004532..7e765b97 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.protobuf) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle.properties b/gradle.properties index 132244e5..23480cc7 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,13 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=false +android.sdk.defaultTargetSdkToCompileSdkIfUnset=true +android.enableAppCompileTimeRClass=true +android.usesSdkInManifest.disallowed=true +android.uniquePackageNames=true +android.dependency.useConstraints=false +android.r8.strictFullModeForKeepRules=true +android.r8.optimizedResourceShrinking=true +android.builtInKotlin=true +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 92c7835c..69f0d876 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,18 @@ [versions] aboutLibraries = "13.2.1" acra = "5.13.1" -agp = "8.13.2" +agp = "9.0.1" auto-service = "1.1.1" autoServiceKsp = "1.2.0" desugar_jdk_libs = "2.1.5" -hiltCompiler = "1.3.0" hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" -kotlin = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" +kotlin = "2.3.10" ksp = "2.3.5" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.01.01" +composeBom = "2026.02.00" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -25,18 +23,18 @@ timber = "5.0.1" tvFoundation = "1.0.0-alpha12" tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.12.3" +activityCompose = "1.12.4" androidx-media3 = "1.9.2" coil = "3.3.0" jellyfin-sdk = "1.7.1" -nav3Core = "1.0.0" +nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.58" +hilt = "2.59.1" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" @@ -83,7 +81,6 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } -kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" } robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" } @@ -135,7 +132,6 @@ androidx-core-testing = { module = "androidx.arch.core:core-testing", version.re [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aaaabb3c..23449a2b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 1e810c11574c61b0cd3c9ddef0ad53da5ac4c587 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:30:03 -0500 Subject: [PATCH 068/176] Some fixes to customize homes (#893) ## Description - Update header when editing a row - Exclude non-empty rows to prevent scroll lock - Only save settings locally if they are different from the source ### Related issues Related to #803 ### Testing Emulator, tested remote & local settings changes ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/RecommendedContent.kt | 19 +++++++++++- .../damontecres/wholphin/ui/main/HomePage.kt | 30 +++++++------------ .../wholphin/ui/main/HomeViewModel.kt | 9 +++++- .../ui/main/settings/HomeSettingsPage.kt | 16 +++++++++- .../ui/main/settings/HomeSettingsViewModel.kt | 18 +++++++++-- 5 files changed, 67 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 174f4665..908574e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.LoadingState @@ -149,8 +150,10 @@ fun RecommendedContent( } LoadingState.Success -> { + var position by rememberPosition() HomePageContent( homeRows = rows, + position = position, onClickItem = { _, item -> viewModel.navigationManager.navigateTo(item.destination()) }, @@ -160,7 +163,21 @@ fun RecommendedContent( onClickPlay = { _, item -> viewModel.navigationManager.navigateTo(Destination.Playback(item)) }, - onFocusPosition = onFocusPosition, + onFocusPosition = { + position = it + val nonEmptyRowBefore = + rows + .subList(0, it.row) + .count { + it is HomeRowLoadingState.Success && it.items.isEmpty() + } + onFocusPosition?.invoke( + RowColumn( + it.row - nonEmptyRowBefore, + it.column, + ), + ) + }, showClock = preferences.appPreferences.interfacePreferences.showClock, onUpdateBackdrop = viewModel::updateBackdrop, modifier = modifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 50acc56f..b9a7cbd7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -117,12 +117,17 @@ fun HomePage( var dialog by remember { mutableStateOf(null) } var showPlaylistDialog by remember { mutableStateOf(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var position by rememberPosition() HomePageContent( homeRows = homeRows, - onClickItem = { position, item -> + position = position, + onFocusPosition = { position = it }, + onClickItem = { clickedPosition, item -> + position = clickedPosition viewModel.navigationManager.navigateTo(item.destination()) }, - onLongClickItem = { position, item -> + onLongClickItem = { clickedPosition, item -> + position = clickedPosition val dialogItems = buildMoreDialogItemsForHome( context = context, @@ -193,19 +198,19 @@ fun HomePage( @Composable fun HomePageContent( homeRows: List, + position: RowColumn, + onFocusPosition: (RowColumn) -> Unit, onClickItem: (RowColumn, BaseItem) -> Unit, onLongClickItem: (RowColumn, BaseItem) -> Unit, onClickPlay: (RowColumn, BaseItem) -> Unit, showClock: Boolean, onUpdateBackdrop: (BaseItem) -> Unit, modifier: Modifier = Modifier, - onFocusPosition: ((RowColumn) -> Unit)? = null, loadingState: LoadingState? = null, listState: LazyListState = rememberLazyListState(), takeFocus: Boolean = true, showEmptyRows: Boolean = false, ) { - var position by rememberPosition() val focusedItem = position.let { (homeRows.getOrNull(it.row) as? HomeRowLoadingState.Success)?.items?.getOrNull(it.column) @@ -329,21 +334,8 @@ fun HomePageContent( cardModifier .onFocusChanged { if (it.isFocused) { - position = - RowColumn(rowIndex, index) - } - if (it.isFocused && onFocusPosition != null) { - val nonEmptyRowBefore = - homeRows - .subList(0, rowIndex) - .count { - it is HomeRowLoadingState.Success && it.items.isEmpty() - } - onFocusPosition.invoke( - RowColumn( - rowIndex - nonEmptyRowBefore, - index, - ), + onFocusPosition?.invoke( + RowColumn(rowIndex, index), ) } }.onKeyEvent { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 58296848..4471194e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -131,7 +131,14 @@ class HomeViewModel ) } } - val rows = deferred.awaitAll() + val rows = + deferred + .awaitAll() + .filter { + // Include only errors & non-empty successes + it is HomeRowLoadingState.Error || + (it is HomeRowLoadingState.Success && it.items.isNotEmpty()) + } Timber.v("Got all rows") _state.update { it.copy( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 17a02b59..d729247a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -33,11 +33,14 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.RowSettings +import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job import kotlinx.coroutines.launch import timber.log.Timber @@ -55,6 +58,7 @@ fun HomeSettingsPage( var showConfirmDialog by remember { mutableStateOf(null) } val state by viewModel.state.collectAsState() + var position by rememberPosition(0, 0) // TODO discover rows val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) @@ -109,7 +113,15 @@ fun HomeSettingsPage( backStack.add(RowSettings(row.id)) scope.launch(ExceptionHandler()) { Timber.v("Scroll to $index") - listState.scrollToItem(index) + listState.animateScrollToItem(index) + // Update backdrop to first item in the row + (state.rowData.getOrNull(index) as? HomeRowLoadingState.Success) + ?.items + ?.firstOrNull() + ?.let { + viewModel.updateBackdrop(it) + } + position = RowColumn(index, 0) } }, modifier = destModifier, @@ -259,6 +271,8 @@ fun HomeSettingsPage( HomePageContent( loadingState = state.loading, homeRows = state.rowData, + position = position, + onFocusPosition = { position = it }, onClickItem = { _, _ -> }, onLongClickItem = { _, _ -> }, onClickPlay = { _, _ -> }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 11fd058c..7400a872 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -81,6 +81,9 @@ class HomeSettingsViewModel val discoverEnabled = seerrServerRepository.active + private var originalLocalSettings: HomePageSettings? = null + private var originalRemoteSettings: HomePageSettings? = null + init { addCloseable { saveToLocal() } viewModelScope.launchIO { @@ -88,6 +91,8 @@ class HomeSettingsViewModel val libraries = navDrawerService.getAllUserLibraries(userDto.id, userDto.tvAccess) val currentSettings = homeSettingsService.currentSettings.first { it != HomePageResolvedSettings.EMPTY } + originalLocalSettings = homeSettingsService.loadFromLocal(userDto.id) + originalRemoteSettings = homeSettingsService.loadFromServer(userDto.id) Timber.v("currentSettings=%s", currentSettings) idCounter = currentSettings.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { @@ -493,9 +498,16 @@ class HomeSettingsViewModel HomePageSettings(rows = rows, SUPPORTED_HOME_PAGE_SETTINGS_VERSION) try { Timber.d("saveToLocal") - val local = homeSettingsService.loadFromLocal(user.id) - // Only save if there are changes - if (local != settings) { + // Only save if there are changes based on original source + val shouldSave = + if (originalLocalSettings != null) { + originalLocalSettings != settings + } else if (originalRemoteSettings != null) { + originalRemoteSettings != settings + } else { + true + } + if (shouldSave) { homeSettingsService.saveToLocal(user.id, settings) homeSettingsService.updateCurrent(settings) showSaveToast() From 44eb50910d4e38666ef3474dd22c9c2ef52c2fa3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:13:38 -0500 Subject: [PATCH 069/176] Fixes unable to dismiss next up during credits (#899) ## Description Fixes an issue where the "Next Up" popup continuously pops up when dismissed if you have "Show next up during credits/outro" enabled. ### Related issues Fixes #895 Bug introduced by #884 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/ui/playback/PlaybackViewModel.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 7ed25311..b3a446d7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -165,7 +165,6 @@ class PlaybackViewModel val currentPlayback = MutableStateFlow(null) val currentItemPlayback = MutableLiveData() val currentSegment = MutableStateFlow(null) - private val autoSkippedSegments = mutableSetOf() val subtitleCues = MutableLiveData>(listOf()) @@ -957,7 +956,10 @@ class PlaybackViewModel } } + // Variables for tracking segment state private var segmentJob: Job? = null + private val autoSkippedSegments = mutableSetOf() + private val outroShownSegments = mutableSetOf() /** * Cancels listening for segments and clears current segment state @@ -965,6 +967,7 @@ class PlaybackViewModel private fun resetSegmentState() { segmentJob?.cancel() autoSkippedSegments.clear() + outroShownSegments.clear() currentSegment.value = null } @@ -1007,7 +1010,8 @@ class PlaybackViewModel if (currentSegment.type == MediaSegmentType.OUTRO && prefs.showNextUpWhen == ShowNextUpWhen.DURING_CREDITS && - playlist != null && playlist.hasNext() + playlist != null && playlist.hasNext() && + outroShownSegments.add(currentSegment.id) ) { val nextItem = playlist.peek() Timber.v("Setting next up during outro to ${nextItem?.id}") From 0276c5f49b2d33e257ebf2569ae9b64bc0e47ecc Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 14 Feb 2026 16:13:45 -0500 Subject: [PATCH 070/176] Fix reordering the nav drawer items not always saving the changes (#898) ## Description Fixes the nav drawer preference reordering & save logic. Also improves the UI a bit with animations. It's also now stored in memory and saved when the dialog is dismissed. ### Related issues Related to #886 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../ui/preferences/NavDrawerPreference.kt | 136 ++++++++++++++++-- .../ui/preferences/PreferencesContent.kt | 5 - .../ui/preferences/PreferencesViewModel.kt | 94 ------------ 3 files changed, 128 insertions(+), 107 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt index 0504d988..afdc93a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/NavDrawerPreference.kt @@ -15,7 +15,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -26,17 +28,44 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import androidx.tv.material3.ListItem import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Switch import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem +import com.github.damontecres.wholphin.data.model.NavPinType +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavDrawerItemState +import com.github.damontecres.wholphin.services.NavDrawerService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.Button +import com.github.damontecres.wholphin.ui.launchDefault +import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import javax.inject.Inject data class NavDrawerPin( val id: String, @@ -83,11 +112,11 @@ private fun List.move( fun NavDrawerPreference( title: String, summary: String?, - items: List, - onSave: (List) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + viewModel: NavDrawerPreferencesViewModel = hiltViewModel(), ) { + val items by viewModel.state.collectAsState() var showDialog by remember { mutableStateOf(false) } ClickPreference( title = title, @@ -99,19 +128,22 @@ fun NavDrawerPreference( if (showDialog) { NavDrawerPreferenceDialog( items = items, - onDismissRequest = { showDialog = false }, + onDismissRequest = { + viewModel.save() + showDialog = false + }, onClick = { index -> val newItems = items.toMutableList().apply { set(index, items[index].let { it.copy(pinned = !it.pinned) }) } - onSave.invoke(newItems) + viewModel.update(newItems) }, onMoveUp = { index -> - onSave(items.move(MoveDirection.UP, index)) + viewModel.update(items.move(MoveDirection.UP, index)) }, onMoveDown = { index -> - onSave(items.move(MoveDirection.DOWN, index)) + viewModel.update(items.move(MoveDirection.DOWN, index)) }, ) } @@ -127,9 +159,12 @@ fun NavDrawerPreferenceDialog( ) { BasicDialog( onDismissRequest = onDismissRequest, + elevation = 3.dp, ) { Column( - modifier = Modifier.padding(16.dp), + modifier = + Modifier + .padding(16.dp), ) { Text( text = stringResource(R.string.nav_drawer_pins), @@ -137,7 +172,9 @@ fun NavDrawerPreferenceDialog( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(bottom = 8.dp), ) + val listState = rememberLazyListState() LazyColumn( + state = listState, verticalArrangement = Arrangement.spacedBy(0.dp), ) { itemsIndexed(items, key = { _, item -> item.id }) { index, item -> @@ -149,7 +186,7 @@ fun NavDrawerPreferenceDialog( onClick = { onClick.invoke(index) }, onMoveUp = { onMoveUp.invoke(index) }, onMoveDown = { onMoveDown.invoke(index) }, - modifier = Modifier, + modifier = Modifier.animateItem(), ) } } @@ -243,3 +280,86 @@ fun NavDrawerPreferenceListItemPreview() { ) } } + +@HiltViewModel +class NavDrawerPreferencesViewModel + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + val preferenceDataStore: DataStore, + val navigationManager: NavigationManager, + val backdropService: BackdropService, + private val rememberTabManager: RememberTabManager, + private val serverRepository: ServerRepository, + private val navDrawerService: NavDrawerService, + private val serverPreferencesDao: ServerPreferencesDao, + private val seerrServerRepository: SeerrServerRepository, + ) : ViewModel() { + val state = MutableStateFlow>(listOf()) + + init { + viewModelScope.launchDefault { + val state = navDrawerService.state.value + val user = serverRepository.currentUser.value + val seerr = seerrServerRepository.active.firstOrNull() + if (state == NavDrawerItemState.EMPTY || user == null || seerr == null) { + return@launchDefault + } + val navDrawerPins = + withContext(Dispatchers.IO) { + serverPreferencesDao + .getNavDrawerPinnedItems(user) + .associateBy { it.itemId } + } + val allItems = state.let { it.items + it.moreItems } + val pins = + allItems + .sortedBy { + navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE + }.mapNotNull { + if (!seerr && it is NavDrawerItem.Discover) { + null + } else { + // Assume pinned if unknown + val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED + NavDrawerPin( + it.id, + it.name(context), + pinned == NavPinType.PINNED, + it, + ) + } + } + this@NavDrawerPreferencesViewModel.state.value = pins + } + } + + fun update(items: List) { + state.update { items } + } + + fun save() { + viewModelScope.launchIO(ExceptionHandler(true)) { + serverRepository.currentUser.value?.let { user -> + serverRepository.currentUserDto.value?.let { userDto -> + if (user.id == userDto.id) { + val toSave = + state.value.mapIndexed { index, item -> + NavDrawerPinnedItem( + user.rowId, + item.id, + if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, + index, + ) + } + serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) + navDrawerService.updateNavDrawer(user, userDto) + } else { + throw IllegalStateException("User IDs do not match") + } + } + } + } + } + } 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 8db91b08..a806c31a 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 @@ -89,7 +89,6 @@ fun PreferencesContent( val currentServer by seerrVm.currentSeerrServer.collectAsState(null) var showPinFlow by remember { mutableStateOf(false) } - val navDrawerPins by viewModel.navDrawerPins.collectAsState(emptyList()) var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } @@ -338,10 +337,6 @@ fun PreferencesContent( NavDrawerPreference( title = stringResource(pref.title), summary = pref.summary(context, null), - items = navDrawerPins, - onSave = { - viewModel.updatePins(it) - }, modifier = Modifier, interactionSource = interactionSource, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 97223b89..709b6d9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -5,37 +5,27 @@ import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.data.ServerPreferencesDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser -import com.github.damontecres.wholphin.data.model.NavDrawerPinnedItem -import com.github.damontecres.wholphin.data.model.NavPinType import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService -import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.RememberTabManager import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -49,52 +39,11 @@ class PreferencesViewModel val backdropService: BackdropService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, - private val navDrawerService: NavDrawerService, - private val serverPreferencesDao: ServerPreferencesDao, private val seerrServerRepository: SeerrServerRepository, private val deviceInfo: DeviceInfo, private val clientInfo: ClientInfo, ) : ViewModel(), RememberTabManager by rememberTabManager { - val navDrawerPins = - navDrawerService.state - .combine( - serverRepository.currentUser.asFlow(), - ) { state, user -> - Pair(state, user) - }.combine(seerrServerRepository.active) { (state, user), seerr -> - Triple(state, user, seerr) - }.map { (state, user, seerr) -> - withContext(Dispatchers.IO) { - val navDrawerPins = - serverPreferencesDao - .getNavDrawerPinnedItems(user!!) - .associateBy { it.itemId } - - val allItems = state.let { it.items + it.moreItems } - val pins = - allItems - .sortedBy { - navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE - }.mapNotNull { - if (!seerr && it is NavDrawerItem.Discover) { - null - } else { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - NavDrawerPin( - it.id, - it.name(context), - pinned == NavPinType.PINNED, - it, - ) - } - } - - pins - } - } - val currentUser get() = serverRepository.currentUser val seerrEnabled = @@ -113,49 +62,6 @@ class PreferencesViewModel } } - private suspend fun fetchNavDrawerPins(user: JellyfinUser) { - navDrawerService.state.map { - val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } - - val allItems = navDrawerService.state.first().let { it.items + it.moreItems } - val pins = - allItems - .sortedBy { navDrawerPins[it.id]?.order?.takeIf { it >= 0 } ?: Int.MAX_VALUE } - .map { - // Assume pinned if unknown - val pinned = navDrawerPins[it.id]?.type ?: NavPinType.PINNED - NavDrawerPin(it.id, it.name(context), pinned == NavPinType.PINNED, it) - } - pins - } - } - - fun updatePins(items: List) { - viewModelScope.launchIO(ExceptionHandler(true)) { - serverRepository.currentUser.value?.let { user -> - serverRepository.currentUserDto.value?.let { userDto -> - if (user.id == userDto.id) { - Timber.v("Updating pins") - val toSave = - items.mapIndexed { index, item -> - NavDrawerPinnedItem( - user.rowId, - item.id, - if (item.pinned) NavPinType.PINNED else NavPinType.UNPINNED, - index, - ) - } - serverPreferencesDao.saveNavDrawerPinnedItems(*toSave.toTypedArray()) - navDrawerService.updateNavDrawer(user, userDto) - } else { - throw IllegalStateException("User IDs do not match") - } - } - } - } - } - fun sendAppLogs() { sendAppLogs(context, api, clientInfo, deviceInfo) } From dc3c6dc7397f98d8ad27f52e62ebf3cbd50e59c3 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 16 Feb 2026 09:04:28 -0500 Subject: [PATCH 071/176] Fix some cases when refresh rate switching didn't work/timed out (#901) ## Description An attempt to fix #769 1. Always fetch the `DisplayManager` when switching refresh rates 2. Use the Activity context instead of Application context ### Related issues Fixes #769 ### Testing I still haven't reproduced #769 myself, but this change works fine on my shield + LG C2 ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 2 +- .../wholphin/services/RefreshRateService.kt | 27 +++++++++---------- .../wholphin/ui/detail/DebugPage.kt | 14 +++++++--- 3 files changed, 24 insertions(+), 19 deletions(-) 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 dcc25af0..34bd871c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -409,7 +409,7 @@ class MainActivity : AppCompatActivity() { } fun changeDisplayMode(modeId: Int) { - lifecycleScope.launch(Dispatchers.Main + ExceptionHandler()) { + lifecycleScope.launch(Dispatchers.Main + ExceptionHandler(autoToast = true)) { val attrs = window.attributes if (attrs.preferredDisplayModeId != modeId) { Timber.d("Switch preferredDisplayModeId to %s", modeId) 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 bc0c8f94..2ca3d1b1 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 @@ -28,21 +28,6 @@ class RefreshRateService constructor( @param:ApplicationContext private val context: Context, ) { - private val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager - private val display get() = displayManager.getDisplay(Display.DEFAULT_DISPLAY) - - val supportedDisplayModes get() = display.supportedModes.orEmpty() - - private val displayModes: List by lazy { - display.supportedModes - .orEmpty() - .map { DisplayMode(it) } - .sortedWith( - compareByDescending({ it.physicalWidth * it.physicalHeight }) - .thenBy { it.refreshRateRounded }, - ) - } - /** * Find the best display mode for the given stream and signal to change to it */ @@ -55,6 +40,18 @@ class RefreshRateService Timber.v("Not switching either refresh rate nor resolution") return@withContext } + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + val displayModes = + display.supportedModes + .orEmpty() + .map { DisplayMode(it) } + .sortedWith( + compareByDescending({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) + val currentDisplayMode = display.mode require(stream.type == MediaStreamType.VIDEO) { "Stream is not video" } val width = stream.width diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt index 0abcdc07..cfae65ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DebugPage.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.ui.detail import android.content.Context +import android.hardware.display.DisplayManager import android.os.Build import android.util.Log +import android.view.Display import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy @@ -32,11 +34,11 @@ import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.data.ItemPlaybackDao import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.UserPreferences -import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -60,13 +62,19 @@ class DebugViewModel constructor( val serverRepository: ServerRepository, val itemPlaybackDao: ItemPlaybackDao, - val refreshRateService: RefreshRateService, val clientInfo: ClientInfo, val deviceInfo: DeviceInfo, ) : ViewModel() { val itemPlaybacks = MutableLiveData>(listOf()) val logcat = MutableLiveData>(listOf()) + val supportedModes by lazy { + val displayManager = + MainActivity.instance.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val display = displayManager.getDisplay(Display.DEFAULT_DISPLAY) + display.supportedModes.orEmpty() + } + init { viewModelScope.launchIO { serverRepository.currentUser.value?.rowId?.let { @@ -260,7 +268,7 @@ fun DebugPage( "Model: ${Build.MODEL}", "API Level: ${Build.VERSION.SDK_INT}", "Display Modes:", - *viewModel.refreshRateService.supportedDisplayModes, + *viewModel.supportedModes, ).forEach { Text( text = it.toString(), From 8c2227bf67b34a44541733ac3529724240be9e9f Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:40:18 -0500 Subject: [PATCH 072/176] Various fixes for home page customization (#902) ## Description Fixes several issues with home page customization such as - Displaying wrong episode-specific display options - Smaller text on cards without images - Prefer fill scaling for posters, like the old home page ### Related issues Related to #803 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/data/model/HomeRowConfig.kt | 11 ++++++----- .../wholphin/services/HomeSettingsService.kt | 4 +++- .../damontecres/wholphin/ui/cards/BannerCard.kt | 2 +- .../wholphin/ui/main/settings/HomeRowPresets.kt | 7 ++++++- .../wholphin/ui/main/settings/HomeRowSettings.kt | 4 ++-- .../ui/main/settings/HomeSettingsViewModel.kt | 6 +++--- 6 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index c11ad321..09622733 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -127,7 +127,7 @@ sealed interface HomeRowConfig { @Serializable @SerialName("TvPrograms") data class TvPrograms( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvPrograms = this.copy(viewOptions = viewOptions) } @@ -138,7 +138,7 @@ sealed interface HomeRowConfig { @Serializable @SerialName("TvChannels") data class TvChannels( - override val viewOptions: HomeRowViewOptions = HomeRowViewOptions(), + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.liveTvDefault, ) : HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): TvChannels = this.copy(viewOptions = viewOptions) } @@ -213,12 +213,12 @@ const val SUPPORTED_HOME_PAGE_SETTINGS_VERSION = 1 data class HomeRowViewOptions( val heightDp: Int = Cards.HEIGHT_2X3_DP, val spacing: Int = 16, - val contentScale: PrefContentScale = PrefContentScale.FIT, + val contentScale: PrefContentScale = PrefContentScale.FILL, val aspectRatio: AspectRatio = AspectRatio.TALL, val imageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, val showTitles: Boolean = false, val useSeries: Boolean = true, - val episodeContentScale: PrefContentScale = PrefContentScale.FIT, + val episodeContentScale: PrefContentScale = PrefContentScale.FILL, val episodeAspectRatio: AspectRatio = AspectRatio.TALL, val episodeImageType: ViewOptionImageType = ViewOptionImageType.PRIMARY, ) { @@ -229,10 +229,11 @@ data class HomeRowViewOptions( aspectRatio = AspectRatio.WIDE, ) - val channelsDefault = + val liveTvDefault = HomeRowViewOptions( heightDp = 96, aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.FIT, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 99ced240..34631934 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -859,8 +859,10 @@ class HomeSettingsService userId = userDto.id, fields = DefaultItemFields, limit = limit, - enableImages = true, enableUserData = true, + enableImages = true, + enableImageTypes = listOf(ImageType.PRIMARY), + imageTypeLimit = 1, ) api.liveTvApi .getRecommendedPrograms(request) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 5a0985c6..e5b57b79 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -124,7 +124,7 @@ fun BannerCard( Text( text = name ?: "", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, modifier = Modifier diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index e0a64221..ff4cfe0a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -31,6 +31,7 @@ data class HomeRowPresets( val videoLibrary: HomeRowViewOptions, val photoLibrary: HomeRowViewOptions, val playlist: HomeRowViewOptions, + val liveTv: HomeRowViewOptions, val genreSize: Int, ) { fun getByCollectionType(collectionType: CollectionType): HomeRowViewOptions = @@ -49,10 +50,11 @@ data class HomeRowPresets( CollectionType.PHOTOS -> photoLibrary + CollectionType.LIVETV -> liveTv + CollectionType.UNKNOWN, CollectionType.MUSIC, CollectionType.BOOKS, - CollectionType.LIVETV, CollectionType.PLAYLISTS, CollectionType.FOLDERS, -> HomeRowViewOptions() @@ -78,6 +80,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = Cards.HEIGHT_2X3_DP, ) } @@ -115,6 +118,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = epHeight, ) } @@ -156,6 +160,7 @@ data class HomeRowPresets( aspectRatio = AspectRatio.SQUARE, contentScale = PrefContentScale.FIT, ), + liveTv = HomeRowViewOptions.liveTvDefault, genreSize = epHeight, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt index 9e861c61..7ac43548 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowSettings.kt @@ -197,7 +197,7 @@ internal object Options { title = R.string.global_content_scale, defaultValue = PrefContentScale.FIT, displayValues = R.array.content_scale, - getter = { it.contentScale }, + getter = { it.episodeContentScale }, setter = { viewOptions, value -> viewOptions.copy(episodeContentScale = value) }, indexToValue = { PrefContentScale.forNumber(it) }, valueToIndex = { it.number }, @@ -219,7 +219,7 @@ internal object Options { title = R.string.image_type, defaultValue = ViewOptionImageType.PRIMARY, displayValues = R.array.image_types, - getter = { it.imageType }, + getter = { it.episodeImageType }, setter = { viewOptions, value -> val aspectRatio = when (value) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 7400a872..41990294 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -306,7 +306,7 @@ class HomeSettingsViewModel title = title, config = HomeRowConfig.TvChannels( - viewOptions = HomeRowViewOptions.channelsDefault, + viewOptions = HomeRowViewOptions.liveTvDefault, ), ) } @@ -655,11 +655,11 @@ class HomeSettingsViewModel } is HomeRowConfig.TvPrograms -> { - it.config.updateViewOptions(preset.tvLibrary) + it.config.updateViewOptions(preset.liveTv) } is HomeRowConfig.TvChannels -> { - it.config + it.config.updateViewOptions(preset.liveTv) } } it.copy(config = newConfig) From 31ff1b20721272be2172ad4191d2c5240dcfe780 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:16 -0500 Subject: [PATCH 073/176] Update dependency org.openapi.generator to v7.20.0 (#904) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | org.openapi.generator | `7.19.0` → `7.20.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/org.openapi.generator:org.openapi.generator.gradle.plugin/7.20.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/org.openapi.generator:org.openapi.generator.gradle.plugin/7.19.0/7.20.0?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 69f0d876..0ca67261 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,7 +42,7 @@ workRuntimeKtx = "2.11.1" paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" -openapi-generator = "7.19.0" +openapi-generator = "7.20.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } From c488c05c12d6a4c0e8774c26902717969663175c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:57:48 -0500 Subject: [PATCH 074/176] Update dependency com.google.devtools.ksp to v2.3.6 (#910) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.devtools.ksp](https://goo.gle/ksp) ([source](https://redirect.github.com/google/ksp)) | `2.3.5` → `2.3.6` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.6?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin/2.3.5/2.3.6?slim=true) | --- ### Release Notes

google/ksp (com.google.devtools.ksp) ### [`v2.3.6`](https://redirect.github.com/google/ksp/releases/tag/2.3.6) [Compare Source](https://redirect.github.com/google/ksp/compare/2.3.5...2.3.6) #### What's Changed - Fixed an issue where module recompilation would fail on Windows environments when KSP2 was enabled ([#​2774](https://redirect.github.com/google/ksp/issues/2774)) - Resolved an issue where generated Java sources were ignored when using Android Kotlin Multiplatform with IP-compatible paths ([#​2744](https://redirect.github.com/google/ksp/issues/2744)) - Fixed a KSP version 2.3.5 CI error exception that does not break build checks ([#​2763](https://redirect.github.com/google/ksp/issues/2763)) - Added symbol-processing-api and common-deps to compile dependencies ([#​2789](https://redirect.github.com/google/ksp/issues/2789)) - Improved the detection of built-in Kotlin by removing the reliance on KotlinBaseApiPlugin ([#​2772](https://redirect.github.com/google/ksp/issues/2772)) - A back-port of a performance optimization in the Intellij / Analysis API ([2785](https://redirect.github.com/google/ksp/pull/2785) ) #### Contributors - Thanks to [@​salmanmkc](https://redirect.github.com/salmanmkc), [@​jaschdoc](https://redirect.github.com/jaschdoc), [@​gurusai-voleti](https://redirect.github.com/gurusai-voleti) and everyone who reported bugs and participated in discussions! **Full Changelog**:
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0ca67261..05bf860b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,7 @@ hiltNavigationCompose = "1.3.0" hiltWork = "1.3.0" kache = "2.1.1" kotlin = "2.3.10" -ksp = "2.3.5" +ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" composeBom = "2026.02.00" From 8bfe77e6e49644daf697449c9cab2f0e71ed04eb Mon Sep 17 00:00:00 2001 From: Cristiano Chelotti Date: Thu, 19 Feb 2026 16:18:20 -0500 Subject: [PATCH 075/176] Fixing seek text label overlap and visibility issues. (#923) ## Description - Smoothly bumps up the title and subtitle text above the seek progress label when the seek bar is focused to prevent overlap. - Adds a semi-transparent black backer to the text label that appears above the seek progress indicator. Helps with visibility when the image in the playback matches the color of the text. Note: the backer is black with Alpha of 0.6 for now because I felt that looked good for contrast across all themes. However, if you prefer it respect a particular theme color, I'd be willing to make that change. ### Related issues In the playback screen, when focusing the seek bar on Android TVs (at least the ones I was able to test), the seek text above the seek bar will overlap with the media title. This is a purely cosmetic issue in the UI, but it seemed worth a fix. See before and after images in in the Screenshots section below This problem is briefly mentioned in point number 2 of [#528 ](https://github.com/damontecres/Wholphin/issues/528) ### Testing Tested manually on TCL model 50S434 running Android TV. Tested manually using AVD emulation on Television (1080p) and Television (4K) preset devices. Reproduced the problem on all 3 devices and confirmed that my fix works across all. ## Screenshots Before: before After: after ## AI or LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../wholphin/ui/playback/PlaybackOverlay.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index ac6aaeed..2b311e71 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -18,10 +19,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -443,6 +446,10 @@ fun PlaybackOverlay( text = (seekProgressMs / 1000L).seconds.toString(), color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 8.dp, vertical = 4.dp), ) } } @@ -525,13 +532,22 @@ fun Controller( seekBarInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, onNextStateFocus: () -> Unit = {}, ) { + val seekBarFocused by seekBarInteractionSource.collectIsFocusedAsState() + val verticalOffset by animateDpAsState( + targetValue = if (seekBarFocused) (-32).dp else 0.dp, + label = "TitleBumpOffset", + ) + Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(start = 16.dp), + modifier = + Modifier + .padding(start = 16.dp) + .offset(y = verticalOffset), ) { title?.let { Text( From f57cba85f29e55ad547ae92ad73e6c223a5bff4b Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:28:21 +1100 Subject: [PATCH 076/176] Title/subtitle style update (#775) ## Description This PR updates cards that have a title and subtitle, so that they are visually distinct instead of sharing the same font and style. Titles use bodyMedium typography with semiBold weight. Subtitles use bodySmall typography with normal weight. This affects: - PersonCard - EpisodeCard - DiscoverItemCard - GridCard - SeasonCard ### Related issues Related to [735](https://github.com/damontecres/Wholphin/issues/735) ### Screenshots Screenshot_20260125_190057 ### AI/LLM usage No LLM usage --- .../damontecres/wholphin/ui/cards/DiscoverItemCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt | 6 ++++++ .../com/github/damontecres/wholphin/ui/cards/GridCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/PersonCard.kt | 5 +++++ .../com/github/damontecres/wholphin/ui/cards/SeasonCard.kt | 6 ++++++ 5 files changed, 27 insertions(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt index 4e1d0e86..a7df0cb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -183,6 +184,8 @@ fun DiscoverItemCard( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -193,6 +196,8 @@ fun DiscoverItemCard( text = item?.releaseDate?.year?.toString() ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt index e4ffa5ee..279ca7b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/EpisodeCard.kt @@ -22,11 +22,13 @@ 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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AppColors @@ -130,6 +132,8 @@ fun EpisodeCard( text = dto?.seriesName ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -140,6 +144,8 @@ fun EpisodeCard( text = item?.name ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt index 7b653865..c191a70d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GridCard.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -113,6 +114,8 @@ fun GridCard( text = item?.title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, overflow = TextOverflow.Ellipsis, modifier = Modifier @@ -124,6 +127,8 @@ fun GridCard( text = item?.subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt index 47fd4e94..2ce33e09 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/PersonCard.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -174,6 +175,8 @@ fun PersonCard( text = name ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -185,6 +188,8 @@ fun PersonCard( text = role, maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt index c2cbd435..b066d372 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/SeasonCard.kt @@ -19,11 +19,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Card import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.ui.AspectRatios @@ -178,6 +180,8 @@ fun SeasonCard( text = title ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, modifier = Modifier .fillMaxWidth() @@ -188,6 +192,8 @@ fun SeasonCard( text = subtitle ?: "", maxLines = 1, textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, modifier = Modifier .fillMaxWidth() From 9daf679f7d455d099da068c14733bff054eefb8b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:18:14 -0500 Subject: [PATCH 077/176] Fix some image issues (#926) ## Description Fixes/improves thumb image handling by falling back to backdrop images if available. Episodes without a parent thumb/backdrop will fall back to their primary image. Also fixes the nav drawer selected item not updating (bug introduced by #886). ### Related issues Fixes #642 Fixes #914 ### Testing Emulator in various situations with missing thumbs ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/ImageUrlService.kt | 65 ++++++++++++++++++- .../wholphin/services/LatestNextUpService.kt | 7 ++ .../wholphin/ui/cards/BannerCard.kt | 2 +- .../ui/components/CollectionFolderGrid.kt | 7 +- .../damontecres/wholphin/ui/nav/NavDrawer.kt | 54 +++++++++++++++ 5 files changed, 132 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 39967e24..3dfc24c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -30,6 +30,9 @@ class ImageUrlService useSeriesForPrimary: Boolean, imageTags: Map, imageType: ImageType, + parentThumbId: UUID? = null, + parentBackdropId: UUID? = null, + backdropTags: List = emptyList(), fillWidth: Int? = null, fillHeight: Int? = null, ): String? = @@ -54,8 +57,65 @@ class ImageUrlService } } + ImageType.THUMB -> { + if (useSeriesForPrimary && parentThumbId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // Use parent's thumb + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (useSeriesForPrimary && parentBackdropId != null && + (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) + ) { + // No parent thumb, so use backdrop instead + getItemImageUrl( + itemId = parentBackdropId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) { + getItemImageUrl( + itemId = parentThumbId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (useSeriesForPrimary && + parentThumbId == null && + itemType == BaseItemKind.EPISODE && + imageType !in imageTags + ) { + // Workaround to fall back to episode image if no parent thumb + getItemImageUrl( + itemId = itemId, + imageType = ImageType.PRIMARY, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (imageType !in imageTags && backdropTags.isNotEmpty()) { + // If no thumb, use backdrop if available + getItemImageUrl( + itemId = itemId, + imageType = ImageType.BACKDROP, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } + } + ImageType.PRIMARY, - ImageType.THUMB, ImageType.BANNER, -> { if (useSeriesForPrimary && seriesId != null && @@ -108,6 +168,9 @@ class ImageUrlService useSeriesForPrimary = item.useSeriesForPrimary, imageTags = item.data.imageTags.orEmpty(), imageType = imageType, + parentThumbId = item.data.parentThumbItemId, + parentBackdropId = item.data.parentBackdropItemId, + backdropTags = item.data.backdropImageTags.orEmpty(), fillWidth = fillWidth, fillHeight = fillHeight, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 9342f63a..e30a39ca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -20,6 +20,7 @@ import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.UserDto import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetNextUpRequest @@ -60,6 +61,12 @@ class LatestNextUpService remove(BaseItemKind.EPISODE) } }, + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ), ) val items = api.itemsApi diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index e5b57b79..294df8f6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -95,7 +95,7 @@ fun BannerCard( null } } - var imageError by remember { mutableStateOf(false) } + var imageError by remember(imageUrl) { mutableStateOf(false) } Card( modifier = modifier.size(cardHeight * aspectRatio, cardHeight), onClick = onClick, 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 c44f3b3c..4c672998 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 @@ -346,7 +346,12 @@ class CollectionFolderViewModel filter.applyTo( GetItemsRequest( parentId = item?.id, - enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB), + enableImageTypes = + listOf( + ImageType.PRIMARY, + ImageType.THUMB, + ImageType.BACKDROP, + ), includeItemTypes = includeItemTypes, recursive = recursive, excludeItemIds = item?.let { listOf(item.id) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 3527a95e..f0276ffd 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.KeyboardArrowLeft import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -54,6 +55,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.findViewTreeViewModelStoreOwner +import androidx.lifecycle.viewModelScope import androidx.tv.material3.DrawerState import androidx.tv.material3.DrawerValue import androidx.tv.material3.Icon @@ -77,7 +79,9 @@ import com.github.damontecres.wholphin.services.SetupNavigationManager import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.components.TimeDisplay import com.github.damontecres.wholphin.ui.ifElse +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.setup.UserIconCardImage import com.github.damontecres.wholphin.ui.spacedByWithFooter import com.github.damontecres.wholphin.ui.theme.LocalTheme @@ -87,6 +91,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.CollectionType +import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -145,6 +150,54 @@ class NavDrawerViewModel } fun getUserImage(user: JellyfinUser): String = api.imageApi.getUserImageUrl(user.id) + + fun updateSelectedIndex() { + viewModelScope.launchDefault { + val asDestinations = + ( + state.value.items + + listOf( + NavDrawerItem.More, + NavDrawerItem.Discover, + ) + state.value.moreItems + ).map { + if (it is ServerNavDrawerItem) { + it.destination + } else if (it is NavDrawerItem.Favorites) { + Destination.Favorites + } else if (it is NavDrawerItem.Discover) { + Destination.Discover + } else { + null + } + } + + val backstack = navigationManager.backStack.toList().reversed() + for (i in 0..= 0) { + idx + } else { + null + } + } + Timber.v("Found $index => $key") + if (index != null) { + selectedIndex.setValueOnMain(index) + break + } + } + } + } + } } sealed interface NavDrawerItem { @@ -208,6 +261,7 @@ fun NavDrawer( key = "${server.id}_${user.id}", // Keyed to the server & user to ensure its reset when switching either ), ) { + LaunchedEffect(Unit) { viewModel.updateSelectedIndex() } val scope = rememberCoroutineScope() val context = LocalContext.current val density = LocalDensity.current From 219e4b01dba84d9182780c7fd4eac86915258ee4 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:18:39 -0500 Subject: [PATCH 078/176] Add a collection or playlist to home page (#915) ## Description Adds a basic way to add a collection or playlist as a row on the home page. There's a simple search dialog to find the collection or playlist. ### Related issues Related to #399 & #803 ### Testing Emulator testing ## Screenshots N/A ## AI or LLM usage None --- .../ui/detail/search/SearchForDialog.kt | 248 ++++++++++++++++++ .../ui/detail/search/SearchForViewModel.kt | 71 +++++ .../main/settings/HomeLibraryRowTypeList.kt | 20 ++ .../ui/main/settings/HomeSettingsAddRow.kt | 14 +- .../ui/main/settings/HomeSettingsPage.kt | 35 ++- .../ui/main/settings/HomeSettingsViewModel.kt | 44 +++- 6 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt new file mode 100644 index 00000000..6717527e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -0,0 +1,248 @@ +package com.github.damontecres.wholphin.ui.detail.search + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +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.Cards +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.BasicDialog +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.SearchEditTextBox +import com.github.damontecres.wholphin.ui.components.VoiceSearchButton +import com.github.damontecres.wholphin.ui.main.SearchResult +import kotlinx.coroutines.delay +import org.jellyfin.sdk.model.api.BaseItemKind + +@Composable +fun SearchForContent( + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, + modifier: Modifier = Modifier, + viewModel: SearchForViewModel = hiltViewModel(key = searchType.serialName), +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + val state by viewModel.state.collectAsState() + + var query by rememberSaveable { mutableStateOf("") } + val searchFocusRequester = remember { FocusRequester() } + val focusRequester = remember { FocusRequester() } + + var immediateSearchQuery by rememberSaveable { mutableStateOf(null) } + + LifecycleResumeEffect(Unit) { + onPauseOrDispose { + viewModel.voiceInputManager.stopListening() + } + } + + fun triggerImmediateSearch(searchQuery: String) { + immediateSearchQuery = searchQuery + viewModel.search(searchType, searchQuery) + } + + LaunchedEffect(query) { + when { + immediateSearchQuery == query -> { + immediateSearchQuery = null + } + + else -> { + delay(750L) + viewModel.search(searchType, query) + } + } + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth(), + ) { + var isSearchActive by remember { mutableStateOf(false) } + var isTextFieldFocused by remember { mutableStateOf(false) } + val textFieldFocusRequester = remember { FocusRequester() } + + BackHandler(isTextFieldFocused) { + when { + isSearchActive -> { + isSearchActive = false + keyboardController?.hide() + } + + else -> { + focusManager.moveFocus(FocusDirection.Next) + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .focusGroup() + .focusRestorer(textFieldFocusRequester) + .focusRequester(searchFocusRequester), + ) { + VoiceSearchButton( + onSpeechResult = { spokenText -> + query = spokenText + triggerImmediateSearch(spokenText) + }, + voiceInputManager = viewModel.voiceInputManager, + ) + + SearchEditTextBox( + value = query, + onValueChange = { + isSearchActive = true + query = it + }, + onSearchClick = { triggerImmediateSearch(query) }, + readOnly = !isSearchActive, + modifier = + Modifier + .focusRequester(textFieldFocusRequester) + .onFocusChanged { state -> + isTextFieldFocused = state.isFocused + if (!state.isFocused) isSearchActive = false + }.onPreviewKeyEvent { event -> + val isActivationKey = + event.key in listOf(Key.DirectionCenter, Key.Enter) + if (event.type == KeyEventType.KeyUp && isActivationKey && !isSearchActive) { + isSearchActive = true + keyboardController?.show() + true + } else { + false + } + }, + ) + } + } + + when (val st = state.results) { + is SearchResult.Error -> { + ErrorMessage("Error", st.ex) + } + + SearchResult.NoQuery -> { + // no-op + } + + SearchResult.Searching -> { + Text( + text = stringResource(R.string.searching), + ) + } + + is SearchResult.SuccessSeerr -> { + Text( + text = "Not supported", + color = MaterialTheme.colorScheme.error, + ) + } + + is SearchResult.Success -> { + if (st.items.isEmpty()) { + Text( + text = stringResource(R.string.no_results), + ) + } else { + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + ItemRow( + title = titleRes?.let { stringResource(it) } ?: "", + items = st.items, + onClickItem = { _, item -> onClick.invoke(item) }, + onLongClickItem = { _, _ -> }, + modifier = Modifier.focusRequester(focusRequester), + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = { + onClick.invoke() + }, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + modifier = mod, + ) + }, + ) + } + } + } + } +} + +@Composable +fun SearchForDialog( + onDismissRequest: () -> Unit, + searchType: BaseItemKind, + onClick: (BaseItem) -> Unit, +) { + BasicDialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + ), + ) { + SearchForContent( + searchType = searchType, + onClick = onClick, + modifier = + Modifier + .padding(8.dp) + .fillMaxWidth(.8f) + .fillMaxHeight(.66f), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt new file mode 100644 index 00000000..baa98ef1 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForViewModel.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.ui.detail.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.components.VoiceInputManager +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.main.SearchResult +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SearchForViewModel + @Inject + constructor( + private val api: ApiClient, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + val voiceInputManager: VoiceInputManager, + ) : ViewModel() { + val state = MutableStateFlow(SearchForState()) + + init { + state.value = SearchForState() + } + + fun search( + searchType: BaseItemKind, + query: String, + ) { + viewModelScope.launchIO { + if (state.value.query != query) { + if (query.isBlank()) { + state.update { SearchForState(query, SearchResult.NoQuery) } + return@launchIO + } + state.update { SearchForState(query, SearchResult.Searching) } + try { + val request = + GetItemsRequest( + userId = serverRepository.currentUser.value?.id, + searchTerm = query, + includeItemTypes = listOf(searchType), + recursive = true, + fields = SlimItemFields, + ) + val pager = ApiRequestPager(api, request, GetItemsRequestHandler, viewModelScope).init() + state.update { SearchForState(query, SearchResult.Success(pager)) } + } catch (ex: Exception) { + Timber.e(ex) + state.update { SearchForState(query, SearchResult.Error(ex)) } + } + } + } + } + } + +data class SearchForState( + val query: String = "", + val results: SearchResult = SearchResult.NoQuery, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 87ce4d39..64cdd246 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -89,6 +89,24 @@ fun getSupportedRowTypes(library: Library): List { ) } + library.collectionType == CollectionType.BOXSETS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.COLLECTION, + ) + } + + library.collectionType == CollectionType.PLAYLISTS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.GENRES, + LibraryRowType.PLAYLIST, + ) + } + else -> { listOf( LibraryRowType.RECENTLY_ADDED, @@ -109,4 +127,6 @@ enum class LibraryRowType( TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), + COLLECTION(R.string.collections), + PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt index ab48de30..97249aa4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsAddRow.kt @@ -69,11 +69,17 @@ fun HomeSettingsAddRow( TitleText(stringResource(R.string.more)) HorizontalDivider() } - item { + itemsIndexed( + listOf( + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + ), + ) { index, type -> HomeSettingsListItem( selected = false, - headlineText = stringResource(MetaRowType.FAVORITES.stringId), - onClick = { onClickMeta.invoke(MetaRowType.FAVORITES) }, + headlineText = stringResource(type.stringId), + onClick = { onClickMeta.invoke(type) }, modifier = Modifier, ) } @@ -99,4 +105,6 @@ enum class MetaRowType( COMBINED_CONTINUE_WATCHING(R.string.combine_continue_next), FAVORITES(R.string.favorites), DISCOVER(R.string.discover), + COLLECTION(R.string.collection), + PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index d729247a..26126b82 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.detail.search.SearchForDialog import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.settings.HomeSettingsDestination.ChooseRowType @@ -43,6 +44,7 @@ import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber val settingsWidth = 360.dp @@ -56,6 +58,7 @@ fun HomeSettingsPage( val listState = rememberLazyListState() val backStack = rememberNavBackStack(HomeSettingsDestination.RowList) var showConfirmDialog by remember { mutableStateOf(null) } + var searchForDialog by remember { mutableStateOf(null) } val state by viewModel.state.collectAsState() var position by rememberPosition(0, 0) @@ -149,6 +152,14 @@ fun HomeSettingsPage( MetaRowType.DISCOVER -> { backStack.add(HomeSettingsDestination.ChooseDiscover) } + + MetaRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + MetaRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } } }, modifier = destModifier, @@ -159,7 +170,19 @@ fun HomeSettingsPage( HomeLibraryRowTypeList( library = dest.library, onClick = { type -> - addRow { viewModel.addRow(dest.library, type) } + when (type) { + LibraryRowType.COLLECTION -> { + searchForDialog = BaseItemKind.BOX_SET + } + + LibraryRowType.PLAYLIST -> { + searchForDialog = BaseItemKind.PLAYLIST + } + + else -> { + addRow { viewModel.addRow(dest.library, type) } + } + } }, modifier = destModifier, ) @@ -298,6 +321,16 @@ fun HomeSettingsPage( }, ) } + searchForDialog?.let { searchType -> + SearchForDialog( + onDismissRequest = { searchForDialog = null }, + searchType = searchType, + onClick = { + searchForDialog = null + addRow { viewModel.addRow(searchType, it) } + }, + ) + } } data class ShowConfirm( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 41990294..73d659bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -18,6 +18,8 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig.NextUp import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyAdded import com.github.damontecres.wholphin.data.model.HomeRowConfig.RecentlyReleased import com.github.damontecres.wholphin.data.model.HomeRowConfig.Suggestions +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvChannels +import com.github.damontecres.wholphin.data.model.HomeRowConfig.TvPrograms import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.preferences.AppPreferences @@ -230,8 +232,11 @@ class HomeSettingsViewModel ) } - MetaRowType.FAVORITES -> { - throw IllegalArgumentException("Should use addRow(BaseItemKind) instead") + MetaRowType.FAVORITES, + MetaRowType.COLLECTION, + MetaRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Should use a different addRow() instead") } MetaRowType.DISCOVER -> { @@ -305,7 +310,7 @@ class HomeSettingsViewModel id = id, title = title, config = - HomeRowConfig.TvChannels( + TvChannels( viewOptions = HomeRowViewOptions.liveTvDefault, ), ) @@ -316,7 +321,7 @@ class HomeSettingsViewModel HomeRowConfigDisplay( id = id, title = title, - config = HomeRowConfig.TvPrograms(), + config = TvPrograms(), ) } @@ -328,6 +333,12 @@ class HomeSettingsViewModel config = RecentlyAdded(library.itemId), ) } + + LibraryRowType.COLLECTION, + LibraryRowType.PLAYLIST, + -> { + throw IllegalArgumentException("Use different addRow") + } } updateState { it.copy( @@ -357,6 +368,31 @@ class HomeSettingsViewModel fetchRowData() } + fun addRow( + type: BaseItemKind, + parent: BaseItem, + ) = viewModelScope.launchIO { + Timber.v("Adding %s row for %s", type, parent.id) + val id = idCounter++ + val newRow = + HomeRowConfigDisplay( + id = id, + title = parent.name ?: "", + config = + HomeRowConfig.ByParent( + parentId = parent.id, + recursive = true, + ), + ) + updateState { + it.copy( + loading = LoadingState.Loading, + rows = it.rows.toMutableList().apply { add(newRow) }, + ) + } + fetchRowData() + } + fun updateViewOptions( rowId: Int, viewOptions: HomeRowViewOptions, From 65f20e2daf97d471eec6c17bd612905f23181ebb Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:50:55 -0500 Subject: [PATCH 079/176] Fix a few customize home bugs (#929) ## Description Hopefully this is the last round of bug fixes for customizing the home page! Fixes a possible crash when scrolling after adding a row after resetting to default or loading remote settings that have more rows than the original settings Fixes some incorrect titles ### Related issues Related to #399 & #803 ### Testing Emulator & nvidia shield ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 16 ++++++++++--- .../main/settings/HomeLibraryRowTypeList.kt | 2 +- .../ui/main/settings/HomeSettingsPage.kt | 11 ++++++--- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 24 ++++++++++++------- app/src/main/res/values/strings.xml | 5 ++-- 6 files changed, 41 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 34631934..93dc32d2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library +import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -511,7 +512,11 @@ class HomeSettingsService } is HomeRowConfig.Favorite -> { - val name = context.getString(R.string.favorites) // TODO "Favorite " + val name = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[config.kind]!!), + ) HomeRowConfigDisplay(id, name, config) } @@ -785,6 +790,11 @@ class HomeSettingsService } is HomeRowConfig.Favorite -> { + val title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[row.kind]!!), + ) if (row.kind == BaseItemKind.PERSON) { val request = GetPersonsRequest( @@ -801,7 +811,7 @@ class HomeSettingsService .map { BaseItem(it, true) } .let { Success( - context.getString(R.string.favorites), // TODO + title, it, row.viewOptions, ) @@ -822,7 +832,7 @@ class HomeSettingsService .map { BaseItem(it, row.viewOptions.useSeries) } .let { Success( - context.getString(R.string.favorites), // TODO + title, it, row.viewOptions, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 64cdd246..4236c03a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -127,6 +127,6 @@ enum class LibraryRowType( TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), - COLLECTION(R.string.collections), + COLLECTION(R.string.collection), PLAYLIST(R.string.playlist), } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 26126b82..1185f719 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -66,13 +66,18 @@ fun HomeSettingsPage( val discoverEnabled = false // by viewModel.discoverEnabled.collectAsState(false) // Adds a row, waits until its done loading, then scrolls to the new row - fun addRow(func: () -> Job) { + fun addRow( + scrollToBottom: Boolean = true, + func: () -> Job, + ) { scope.launch(ExceptionHandler(autoToast = true)) { while (backStack.size > 1) { backStack.removeAt(backStack.lastIndex) } func.invoke().join() - listState.animateScrollToItem(state.rows.lastIndex) + if (scrollToBottom) { + listState.animateScrollToItem(state.rows.lastIndex) + } } } @@ -273,7 +278,7 @@ fun HomeSettingsPage( onClickReset = { showConfirmDialog = ShowConfirm(R.string.overwrite_local_settings) { - viewModel.resetToDefault() + addRow(false) { viewModel.resetToDefault() } } }, modifier = destModifier, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 231243ce..5a0ac965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -141,7 +141,7 @@ fun HomeSettingsRowList( ) } item { - TitleText(stringResource(R.string.home_rows)) + TitleText(stringResource(R.string.home_rows) + " (${state.rows.size})") HorizontalDivider() } itemsIndexed(state.rows, key = { _, row -> row.id }) { index, row -> diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 73d659bc..9110af12 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -114,7 +114,7 @@ class HomeSettingsViewModel } private suspend fun fetchRowData() { - val limit = 6 + val limit = 8 val semaphore = Semaphore(4) val rows = serverRepository.currentUserDto.value?.let { userDto -> @@ -219,7 +219,7 @@ class HomeSettingsViewModel MetaRowType.NEXT_UP -> { HomeRowConfigDisplay( id = id, - title = context.getString(R.string.continue_watching), + title = context.getString(R.string.next_up), config = NextUp(), ) } @@ -356,7 +356,11 @@ class HomeSettingsViewModel val newRow = HomeRowConfigDisplay( id = id, - title = context.getString(favoriteOptions[type]!!), + title = + context.getString( + R.string.favorite_items, + context.getString(favoriteOptions[type]!!), + ), config = HomeRowConfig.Favorite(type), ) updateState { @@ -480,6 +484,7 @@ class HomeSettingsViewModel result.rows.mapIndexed { index, config -> homeSettingsService.resolve(index, config) } + idCounter = newRows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy(rows = newRows) } @@ -509,6 +514,7 @@ class HomeSettingsViewModel val result = homeSettingsService.parseFromWebConfig(user.id) if (result != null) { Timber.v("Got web settings") + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { it.copy(rows = result.rows) } @@ -593,17 +599,17 @@ class HomeSettingsViewModel } } - fun resetToDefault() { + fun resetToDefault() = viewModelScope.launchIO { val userId = serverRepository.currentUser.value?.id ?: return@launchIO _state.update { it.copy(loading = LoadingState.Loading) } val result = homeSettingsService.createDefault(userId) + idCounter = result.rows.maxOfOrNull { it.id }?.plus(1) ?: 0 _state.update { - it.copy(rows = result.rows) + it.copy(rows = result.rows, rowData = emptyList()) } fetchRowData() } - } private suspend fun showSaveToast() = showToast( @@ -729,9 +735,9 @@ data class HomePageSettingsState( val EMPTY = HomePageSettingsState( LoadingState.Pending, - listOf(), - listOf(), - listOf(), + emptyList(), + emptyList(), + emptyList(), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d7b1b5c..c4f3db4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -188,6 +188,7 @@ Samples Featurettes Shorts + Favorite %s Trailers @@ -505,8 +506,8 @@ Apply to all rows Customize home page Home rows - Load from server - Save to server + Load from server user profile + Save to server user profile Load from web client Use series image Add row for %1$s From a11474061417314dbe58c0fa4605bb3059a96dfa Mon Sep 17 00:00:00 2001 From: idezentas Date: Tue, 10 Feb 2026 20:38:16 +0000 Subject: [PATCH 080/176] Translated using Weblate (Turkish) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 745da44f..12367b44 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -436,4 +436,11 @@ Uzaklaştır Slayt Gösterisi Süresi Slayt gösterisi sırasında videoları oynat + + %s gün + %s günler + + Medya bilgisini sunucuya gönder + Sınırsız + Sıradaki bölümlerde kalacağı gün sayısı From 9e1e065b958264cc5f450d623455c5095d149f0c Mon Sep 17 00:00:00 2001 From: SimonHung Date: Wed, 11 Feb 2026 12:30:41 +0000 Subject: [PATCH 081/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 330f5dd0..cc5700b1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -411,4 +411,10 @@ 幻燈片播放時播放影片 圖形字幕不透明度 亮度 + + %s 天 + + 將媒體訊息日誌傳送到伺服器 + 無限制 + 接下來播放的保留天數 From 8530ba334f9d8ff7f07ef83a27e2270aa2a8d07c Mon Sep 17 00:00:00 2001 From: arcker95 Date: Wed, 11 Feb 2026 02:19:17 +0000 Subject: [PATCH 082/176] Translated using Weblate (Italian) Currently translated at 99.7% (386 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e5aa51b4..76b7d093 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -444,4 +444,10 @@ Durata slideshow Riproduci video durante lo slideshow Invia log informazioni media al server + + %s giorno + %s giorni + %s giorni + + Nessun limite From fe9968227fd856ac362752043ebaf123f6be30fb Mon Sep 17 00:00:00 2001 From: Fjuro Date: Wed, 11 Feb 2026 13:08:44 +0000 Subject: [PATCH 083/176] Translated using Weblate (Czech) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 1a328c33..0f005aa8 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -475,4 +475,12 @@ Trvání prezentace Přehrávat videa během prezentace Odesílat protokol informací o médiích serveru + + %s den + %s dny + %s dní + %s dní + + Bez omezení + Max. počet dnů v Dalších v pořadí From 543e521972dae3426825eab7d10d955458ff664a Mon Sep 17 00:00:00 2001 From: idezentas Date: Wed, 11 Feb 2026 19:07:07 +0000 Subject: [PATCH 084/176] Translated using Weblate (Turkish) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 12367b44..dbd3cf13 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -66,7 +66,7 @@ Diğer Filmler - + Filmler Ad Sıradaki bölümler @@ -81,7 +81,7 @@ Yol Kişiler - + Kişiler Oynatma Sayısı Buradan oynat @@ -152,7 +152,7 @@ Fragman Fragmanlar - + Fragmanlar Kayıt Takvimi Rehber @@ -160,7 +160,7 @@ Sezonlar Diziler - + Diziler Arayüz Bilinmiyor From 99d2f37114db97036022df6d76639091470ef61f Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 12 Feb 2026 00:42:05 +0000 Subject: [PATCH 085/176] Translated using Weblate (Italian) Currently translated at 100.0% (387 of 387 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 76b7d093..595ceb91 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -450,4 +450,5 @@ %s giorni Nessun limite + Giorni massimi in Prossimo From a0cf71166c19de14f46474999f05bf86484f4a12 Mon Sep 17 00:00:00 2001 From: flipip Date: Thu, 12 Feb 2026 15:58:30 +0000 Subject: [PATCH 086/176] Translated using Weblate (German) Currently translated at 97.4% (382 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/de/ --- app/src/main/res/values-de/strings.xml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eddd471d..013f5ccd 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -19,7 +19,7 @@ Bestätigen Weiterschauen Kritiken - %.1f Sekunden + %.2f Sekunden Standard Löschen Gestorben @@ -321,7 +321,7 @@ Nein Titel anzeigen Wiederholen - Generell + Allgemein Keine Trailer Trailer abspielen Lokal @@ -417,4 +417,19 @@ Diashow anhalten Helligkeit Kontrast + Verkleinern + Vergrößern + Gerät erfolgreich authorisiert + Sättigung + Farbton + + %s Tag + %s Tage + + Der Code muss aus 6 Ziffern bestehen + HDR-Untertitelstil + Dauer der Diashow + Videos während der Diashow abspielen + Maximale Tage in \"Als nächstes\" + Quick Connect From c1bd8010b3c5b7f31cca80ce795298ce63b976ad Mon Sep 17 00:00:00 2001 From: arcker95 Date: Thu, 12 Feb 2026 20:46:33 +0000 Subject: [PATCH 087/176] Translated using Weblate (Italian) Currently translated at 100.0% (392 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 595ceb91..1cbb364b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -451,4 +451,9 @@ Nessun limite Giorni massimi in Prossimo + Connessione rapida + Autorizza un altro dispositivo per accedere al tuo account + Inserisci il codice di Connessione rapida + Il codice deve essere di 6 cifre + Dispositivo autorizzato con successo From f14514f2794bac4e178f9b6acf50d052f7b1938c Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Thu, 12 Feb 2026 01:23:28 +0000 Subject: [PATCH 088/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (392 of 392 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b08bfeb5..3e8abf29 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -417,4 +417,9 @@ 无限制 即将播放中的最大天数 + 快速连接 + 授权其他设备登录您的账号 + 输入快速连接代码 + 代码必须为六位数字 + 设备授权成功 From 8253b62775f471a534fff7cc366a00717fd80585 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 13 Feb 2026 01:27:43 +0000 Subject: [PATCH 089/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 94.6% (392 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index cc5700b1..1406e232 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -417,4 +417,9 @@ 將媒體訊息日誌傳送到伺服器 無限制 接下來播放的保留天數 + 快速連線 + 授權其他裝置登入您的帳號 + 輸入快速連線驗證碼 + 驗證碼必須為6位數 + 裝置授權成功 From b9cb93f22e85c3052d31c088f2fca5b2f182abdf Mon Sep 17 00:00:00 2001 From: SimonHung Date: Fri, 13 Feb 2026 04:03:48 +0000 Subject: [PATCH 090/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 96.1% (398 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1406e232..4025fe30 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -422,4 +422,10 @@ 輸入快速連線驗證碼 驗證碼必須為6位數 裝置授權成功 + %1$s 類型 + 最近發行於 %1$s + 推薦的 %1$s + 放大海報尺寸 + 縮小海報尺寸 + 使用橫向縮圖 From 29063a20e74787da9aea8d50f8d25f0f749a89b2 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 13 Feb 2026 12:56:18 +0000 Subject: [PATCH 091/176] Translated using Weblate (Italian) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1cbb364b..a3614e19 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -456,4 +456,26 @@ Inserisci il codice di Connessione rapida Il codice deve essere di 6 cifre Dispositivo autorizzato con successo + Aggiungi riga + Generi in %1$s + Recentemente rilasciato in %1$s + Altezza + Applica a tutte le righe + Carica dal server + Aggiungi riga per %1$s + Sovrascrivere le impostazioni sul server? + Sovrascrivere le impostazioni locali? + Suggerimenti per %1$s + Aumenta dimensione di tutte le card + Riduci dimensione di tutte le card + Usa immagini thumbnail + Impostazioni salvate + Mostra preset + Preset integrati per stilizzare rapidamente tutte le righe + Righe home + Per gli episodi + Personalizza home page + Salva sul server + Carica dal client web + Usa immagine delle serie From 8c997518aa00cf27318712df74bedacebe5022aa Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 13 Feb 2026 02:47:52 +0000 Subject: [PATCH 092/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 26 ++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 3e8abf29..b0dbec5b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -117,7 +117,7 @@ 下载时间过长,可能需要重新开始播放 字幕 字幕 - 建议 + 推荐 切换服务器 切换 高分未看 @@ -215,7 +215,7 @@ 默认内容比例 安装更新 安装版本 - 首页每行最大项目数 + 首页行最大项目数 选择默认显示的项目,其他项目将隐藏 自定义抽屉式导航栏项目 点击切换页面 @@ -422,4 +422,26 @@ 输入快速连接代码 代码必须为六位数字 设备授权成功 + 添加行 + %1$s 中的类型 + %1$s 最近发行 + 高度 + 应用到所有行 + 自定义首页 + 首页行 + 从服务器加载 + 保存到服务器 + 从网络客户端加载 + 使用剧集图片 + 为 %1$s 添加行 + 覆盖服务器上的设置? + 覆盖本地设置? + 针对剧集 + %1$s 推荐内容 + 增大所有卡片的尺寸 + 减小所有卡片的尺寸 + 使用缩略图 + 设置已保存 + 显示预设 + 可快速设置所有行样式的内置预设 From ce79afcd68512bf849c1fdef48c34538ee5d94ac Mon Sep 17 00:00:00 2001 From: Fjuro Date: Fri, 13 Feb 2026 14:05:04 +0000 Subject: [PATCH 093/176] Translated using Weblate (Czech) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 0f005aa8..be3d9f6b 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -483,4 +483,31 @@ Bez omezení Max. počet dnů v Dalších v pořadí + Rychlé připojení + Autorizovat jiné zařízení k přihlášení k vašemu účtu + Zadejte kód Rychlého připojení + Kód musí mít 6 číslic + Zařízení úspěšně autorizováno + Přidat řádek + Žánry v %1$s + Nedávno vydáno v %1$s + Výška + Použít na všechny řádky + Přizpůsobit domovskou stránku + Řádky na domovské stránce + Načíst ze serveru + Uložit na server + Načíst z webového klienta + Použít obrázek seriálu + Přidat řádek pro %1$s + Přepsat nastavení na serveru? + Přepsat místní nastavení? + Pro epizody + Návrhy na %1$s + Zvětšit velikost všech karet + Zmenšit velikost všech karet + Použít obrázky miniatur + Nastavení uložena + Zobrazit předvolby + Vestavěné předvolby pro rychlou úpravu všech řádků From 7113b438099655117b300f0faaf69425b4bc5df4 Mon Sep 17 00:00:00 2001 From: idezentas Date: Fri, 13 Feb 2026 14:18:01 +0000 Subject: [PATCH 094/176] Translated using Weblate (Turkish) Currently translated at 100.0% (414 of 414 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index dbd3cf13..117d6e2f 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -443,4 +443,31 @@ Medya bilgisini sunucuya gönder Sınırsız Sıradaki bölümlerde kalacağı gün sayısı + Hızlı Bağlan + Başka bir cihazın giriş yapmasına izin ver + Hızlı Bağlan kodunu girin + Kod 6 haneli olmalıdır + Cihaz başarıyla yetkilendirildi + Satır ekle + %1$s içindeki türler + %1$s kütüphanesinde son çıkanlar + Yükseklik + Tüm satırlara uygula + Ana ekranı özelleştir + Ana ekran satırları + Sunucudan yükle + Sunucuya kaydet + Web istemcisinden yükle + Dizi görselini kullan + %1$s için satır ekle + Sunucudaki ayarların üzerine yazılsın mı? + Yerel ayarların üzerine yazılsın mı? + Bölümler için + %1$s için tavsiyeler + Tüm kartların boyutunu artır + Tüm kartların boyutunu azalt + Küçük resimleri kullan + Ayarlar kaydedildi + Görüntü ön ayarları + Tüm satırları hızlıca stilize etmek için hazır ayarlar From 419cb68af417e527d45c2751fcbf810870c31de1 Mon Sep 17 00:00:00 2001 From: SimonHung Date: Sat, 14 Feb 2026 07:58:13 +0000 Subject: [PATCH 095/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4025fe30..1cae12e0 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -425,7 +425,24 @@ %1$s 類型 最近發行於 %1$s 推薦的 %1$s - 放大海報尺寸 - 縮小海報尺寸 + 放大所有海報尺寸 + 縮小所有海報尺寸 使用橫向縮圖 + 新增一列 + 套用到所有列 + 首頁各列 + 為 %1$s 新增列項目 + 快速設定所有項目為內建預設樣式 + 選擇首頁要顯示的列項目及圖片樣式 + 高度 + 自訂首頁 + 從伺服器載入 + 保存到伺服器 + 從網頁版載入 + 使用劇集圖片 + 覆蓋伺服器上的設定? + 覆蓋本地設定? + 設定已儲存 + 顯示預設樣式 + 針對單集 From 5df9791a3c85f6f9fd972a5d7a4c9a5617efa53a Mon Sep 17 00:00:00 2001 From: arcker95 Date: Sat, 14 Feb 2026 01:27:15 +0000 Subject: [PATCH 096/176] Translated using Weblate (Italian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a3614e19..0aadb5bd 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -478,4 +478,5 @@ Salva sul server Carica dal client web Usa immagine delle serie + Scegli righe e immagini nella home page From 12499d1f38e961222ed8c36ad6a34e6f21d56dea Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sat, 14 Feb 2026 11:32:59 +0000 Subject: [PATCH 097/176] Translated using Weblate (Czech) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index be3d9f6b..f0e3d0be 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -510,4 +510,5 @@ Nastavení uložena Zobrazit předvolby Vestavěné předvolby pro rychlou úpravu všech řádků + Vyberte řádky a obrázky na domovské stránce From 124b6a741e8aec41e2f4564ab8f99126a2ec9915 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Sun, 15 Feb 2026 11:46:24 +0000 Subject: [PATCH 098/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b0dbec5b..49bab214 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -444,4 +444,5 @@ 设置已保存 显示预设 可快速设置所有行样式的内置预设 + 选择首页上的行和图片 From 0d6b82bce0fa74368aab028e4dc73f63c16a8d42 Mon Sep 17 00:00:00 2001 From: DoTheBarrelRoll Date: Sun, 15 Feb 2026 13:11:18 +0000 Subject: [PATCH 099/176] Added translation using Weblate (Finnish) --- app/src/main/res/values-fi/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-fi/strings.xml diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-fi/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 5ce702b4756e076977dd0ecd512ff112d4c50a37 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Mon, 16 Feb 2026 14:00:39 +0000 Subject: [PATCH 100/176] Translated using Weblate (Portuguese) Currently translated at 98.5% (409 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index c2ee4aec..3e5db7b7 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -451,4 +451,26 @@ Enviar registo de informações de mídia para o servidor Sem limite Dias máximos em \'A Seguir\' + Ligação Rápida + Autorize outro dispositivo a iniciar sessão na sua conta + Insira o código de Ligação Rápida + Código deve ter 6 dígitos + Dispositivo autorizado com sucesso + Adicionar linha + Géneros em %1$s + Lançado recentemente em %1$s + Altura + Aplicar a todas as linhas + Personalizar página inicial + Linhas da página inicial + Carregar do servidor + Gravar para o servidor + Carregar a partir do cliente web + Usar imagem da série + Adicionar linha para %1$s + Substituir as definições no servidor? + Substituir as definições locais? + Por episódios + Sugestões para %1$s + Aumentar o tamanho de todos os cartões From d0c44d4342f916122b241ba59744dbb930db1381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Mon, 16 Feb 2026 10:01:24 +0000 Subject: [PATCH 101/176] Translated using Weblate (Estonian) Currently translated at 92.5% (384 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 482554a0..1fb7f6c3 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -427,4 +427,8 @@ Suumi välja Slaidiesitluse kestus Slaidiesitluse ajal esita videoid + + %s päev + %s päeva + From 5965fde6b1b7755bf133903a2b4394fcf3ad45ea Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 16 Feb 2026 07:41:10 +0000 Subject: [PATCH 102/176] Translated using Weblate (Indonesian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 03fd5a93..91708cf7 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -412,4 +412,37 @@ Durasi tayangan slide Putar video selama tayangan slide Kirim log info media ke server + + %s hari + + Koneksi Cepat + Izinkan perangkat lain untuk masuk ke akun kamu + Masukkan kode Koneksi Cepat + Kode harus terdiri dari 6 digit + Perangkat berhasil diizinkan + Tanpa batasan + Batas hari untuk Berikutnya + Tambah baris + Genre di %1$s + Baru saja dirilis di %1$s + Tinggi + Terapkan ke semua baris + Kustomisasi beranda + Baris beranda + Muat dari server + Simpan ke server + Muat dari klien web + Gunakan gambar seri + Tambah baris untuk %1$s + Timpa pengaturan di server? + Timpa pengaturan lokal? + Untuk episode + Rekomendasi untuk %1$s + Perbesar ukuran semua kartu + Perkecil ukuran semua kartu + Gunakan gambar thumbnail + Pengaturan disimpan + Preset tampilan + Preset bawaan untuk kustomisasi cepat semua baris + Pilih baris dan gambar di halaman beranda From 2a3defdcd0f8310cf205e31b634db294b744dd4b Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 16 Feb 2026 21:22:45 +0000 Subject: [PATCH 103/176] Translated using Weblate (Spanish) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 61 +++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 31445b5a..831c6036 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,7 +15,7 @@ Comercial Confirmar Continuar viendo - %.1f segundos + %.2f segundos Eliminar Fallecido Activado @@ -181,7 +181,7 @@ Clip Clips - + Clips Escena eliminada @@ -422,4 +422,61 @@ Solicitar en 4K Decodificación por software AV1 MPV es ahora el reproductor por defecto para HDR.\nPuedes cambiar esto en los ajustes. + + %s día + %s días + %s días + + Conexión Rápida + Autoriza a otro dispositivo para acceder a tu cuenta + Introduce el código de Conexión Rápida + El código debe tener 6 dígitos + Dispositivo autorizado con éxito + Estilo de subtítulos HDR + Opacidad de los subtítulos + Brillo + Contraste + Saturación + Tono de color + Rojo + Verde + Azul + Desenfoque + Guardar en el álbum + Reproducir presentación + Detener presentación + Presentación inicial + No hay más imágenes + Girar a la izquierda + Girar a la derecha + Acercar + Alejar + Duración de la presentación + Reproducir vídeos durante la presentación + Enviar registro de medios al servidor + Sin límite + Límite de días en \"Siguiente\" + Añadir fila + Géneros de %1$s + Altura + Aplicar a todas las filas + Personalizar página de inicio + Filas de inicio + Cargar desde el servidor + Guardar en el servidor + Cargar del cliente web + Usar imágenes de la serie + Agregar fila para %1$s + ¿Sobrescribir ajustes en el servidor? + ¿Sobrescribir ajustes locales? + Para episodios + Sugerencias para %1$s + Aumentar tamaño de todas las tarjetas + Reducir tamaño de todas las tarjetas + Usar miniaturas + Ajustes guardados + Preajustes de visualización + Preajustes integrados para estilizar todas las filas + Elegir filas e imágenes en la página de inicio + Lanzado recientemente en %1$s From 9f66c68e92ed2204740b41c28393159b42ba5e82 Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Mon, 16 Feb 2026 23:43:29 +0000 Subject: [PATCH 104/176] Translated using Weblate (Portuguese) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt/ --- app/src/main/res/values-pt/strings.xml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 3e5db7b7..1fb5fbb5 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -1,6 +1,6 @@ - O Wholphin + Sobre Favorito Adicionar Servidor Adicionar Utilizador @@ -10,8 +10,8 @@ Capítulos Escolher %1$s Eliminar cache de imagens - Colecção - Colecções + Coleção + Coleções Confirmar %.2f segundos Padrão @@ -27,7 +27,7 @@ Tamanho Forçado Ir para - Gravações Activas + Gravações Ativas Local de Nascimento Bitrate Nascido @@ -473,4 +473,10 @@ Por episódios Sugestões para %1$s Aumentar o tamanho de todos os cartões + Usar imagens de prévia + Configurações salvas + Mostrar pré definições + Predefinições embarcadas para definir todas as linhas rapidamente + Escolha as linhas e imagens na página inicial + Diminuir o tamanho de todos os cartões From 42b686f4299ee56c292b370e025b80a62d63e08b Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 16 Feb 2026 17:25:15 +0000 Subject: [PATCH 105/176] Translated using Weblate (French) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 38 +++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c131a379..ae405a53 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -24,7 +24,7 @@ Note de la communauté Une erreur est survenue ! Appuyez sur le bouton pour envoyer les logs à votre serveur. Note des critiques - %.1f secondes + %.2f secondes Décédé(e) Désactivé Téléchargement en cours… @@ -443,4 +443,40 @@ Dézoomer Durée du diaporama Diffuser des vidéos pendant le diaporama + + %s jour + %s jours + %s jours + + Connexion rapide + Autoriser un autre appareil à se connecter à votre compte + Entrez le code de connexion rapide + Le code doit comporter 6 chiffres + Appareil autorisé avec succès + Envoyer le journal des informations multimédias au serveur + Aucune limite + Nombre maximal de jours dans Prochainement + Ajouter une ligne + Genres en %1$s + Récemment publié en %1$s + Hauteur + S\'applique à toutes les lignes + Personnaliser la page d\'accueil + Lignes d\'accueil + Charger depuis le serveur + Enregistrer sur le serveur + Charger à partir du client Web + Utiliser l\'image de la série + Ajouter une ligne pour %1$s + Écraser les paramètres sur le serveur ? + Écraser les paramètres locaux ? + Pour les épisodes + Suggestions pour %1$s + Augmenter la taille pour toutes les cartes + Diminuer la taille pour toutes les cartes + Utilisez des images de miniatures + Paramètres enregistrés + Afficher les préréglages + Préréglages intégrés pour styliser rapidement toutes les lignes + Choisissez des lignes et des images sur la page d\'accueil From 7595fad3a19c5a4182f402d364905eb587b4f572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 17 Feb 2026 21:57:31 +0000 Subject: [PATCH 106/176] Translated using Weblate (Estonian) Currently translated at 94.4% (392 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 1fb7f6c3..8e049eb7 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -431,4 +431,12 @@ %s päev %s päeva + Kiirühendus + Luba muul seadmel logida sinu kasutajakontoga sisse + Sisesta kiirühenduse kood + Kood peab olema kuuenumbriline + Seadme autentimine õnnestus + Salvesta meediumi infologi serverisse + Piirangut pole + Järgmisena esitamisel sisu päevade ülempiir From fb79eff3c7d47cefa9d44366e16648e675656d36 Mon Sep 17 00:00:00 2001 From: American_Jesus Date: Tue, 17 Feb 2026 22:02:41 +0000 Subject: [PATCH 107/176] Added translation using Weblate (Portuguese (Brazil)) --- app/src/main/res/values-pt-rBR/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-pt-rBR/strings.xml diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 46637dca503645fa131a0469a1e6fca514f027c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Tue, 17 Feb 2026 22:16:33 +0000 Subject: [PATCH 108/176] Translated using Weblate (Estonian) Currently translated at 100.0% (415 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 8e049eb7..d4d5d17f 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -439,4 +439,27 @@ Salvesta meediumi infologi serverisse Piirangut pole Järgmisena esitamisel sisu päevade ülempiir + Lisa rida + Žanrid: %1$s + Hiljuti avaldatud: %1$s + Kõrgus + Rakenda kõikidele ridadele + Kohanda kodulehte + Kodulehe/avalehe read + Laadi serverist + Salvesta serverisse + Laadi veebikliendist + Kasuta sarja pilti + Lisa rida: %1$s + Kas kirjutad serveris leiduvad seadistused üle? + Kas kirjutad kohalikud seadistused üle? + Episoodide jaoks + Soovitused: %1$s + Suurenda suurust kõikide kaartide jaoks + Vähenda suurust kõikide kaartide jaoks + Kasuta pisipilte + Seadistused on salvesatatud + Kuvamise eelseadistused + Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks + Vali kodulehe/avalehe read ja pildid From b1196d2df3f54568417c6c360cf541d0d05c0582 Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Thu, 19 Feb 2026 00:20:37 +0000 Subject: [PATCH 109/176] Translated using Weblate (Portuguese (Brazil)) Currently translated at 7.7% (32 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 33 +++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 55344e51..c7889af3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,3 +1,34 @@ - \ No newline at end of file + Sobre + Gravações ativas + Favoritar + Adicionar Servidor + Adicionar Usuário + Áudio + Local de nascimento + Taxa de bits + Nascido + Cancelar gravação + Cancelar gravação da série + Cancelar + Capítulos + Escolha %1$s + Limpar cache de imagens + Coleção + Coleções + Comerciais + Avaliação da comunidade + Ocorreu um erro! Aperte o botão para enviar os logs para seu servidor. + Confirmar + Continue assistindo + Avaliação dos críticos + %.2f segundos + Padrão + Remover + Falecido + Dirigido por %1$s + Diretor + Desabilitado + Servidores encontrados + From 3dbf080e4cad061405fcf98526e73a2410d040ef Mon Sep 17 00:00:00 2001 From: alexkahler Date: Thu, 19 Feb 2026 22:47:04 +0000 Subject: [PATCH 110/176] Added translation using Weblate (Danish) --- app/src/main/res/values-da/strings.xml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/src/main/res/values-da/strings.xml diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml new file mode 100644 index 00000000..55344e51 --- /dev/null +++ b/app/src/main/res/values-da/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file From 9cffc2575642a067f3d5f79969c99105d87117f8 Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 00:16:18 +0000 Subject: [PATCH 111/176] Translated using Weblate (Danish) Currently translated at 21.4% (89 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 93 +++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 55344e51..c54cb27c 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -1,3 +1,94 @@ - \ No newline at end of file + Om + Aktive optagelser + Favorit + Tilføj server + Tilføj bruger + Lyd + Fødested + Bitrate + Født + Annullér optagelse + Annullér serieoptagelse + Annullér + Kapitler + Vælg %1$s + Ryd billedcache + Samling + Samlinger + Brugerbedømmelse + Der opstod en fejl! Tryk på knappen for at sende logfiler til din server. + Bekræft + Fortsæt med at se + Kritikerbedømmelse + %.2f sekunder + Standard + Slet + Død + Instrueret af %1$s + Instruktør + Deaktiveret + Opdagede servere + + Downloader… + Aktiveret + Slutter %1$s + Indtast server-IP eller URL + Indtast serveradresse + Episoder + Fejl ved indlæsning af samling %1$s + Ekstern + Favoritter + Størrelse + Tvunget + Genrer + Gå til serien + Gå til + Skjul afspilningsknapper + Skjul fejlfindingsoplysninger + Skjul + Hjem + Umiddelbar + Intro + #ABCDEFGHIJKLMNOPQRSTUVWXYZÆØÅ + Bibliotek + Licensoplysninger + Live-tv + Indlæser… + Markér hele serien som afspillet? + Markér hele serien som ikke afspillet? + Markér som uset + Markér som set + Maks. bitrate + Mere som dette + Mere + + Film + + + Navn + Næste + Ingen data + Ingen resultater + Ingen servere fundet + Ingen planlagte optagelser + Ingen opdatering tilgængelig + Ingen + Kun tvungne undertekster + Outro + Filsti + + Personer + + + Antal afspilninger + Afspil herfra + Afspil + Vis debugoplysninger for afspilning + Afspilningshastighed + Afspilning + Playlist + Playlists + Forhåndsvisning + From 17b0a09b7a4c518af2e70525960d8491a43f8a9d Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 00:18:39 +0000 Subject: [PATCH 112/176] Translated using Weblate (Danish) Currently translated at 21.9% (91 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index c54cb27c..e5f26aae 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -65,7 +65,7 @@ Mere Film - + Navn Næste @@ -80,7 +80,7 @@ Filsti Personer - + Antal afspilninger Afspil herfra @@ -91,4 +91,6 @@ Playlist Playlists Forhåndsvisning + Profilindstillinger + From 9b10fcd6596d53c9fa93e73e0a82db326c8f63de Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 02:11:44 +0000 Subject: [PATCH 113/176] Translated using Weblate (Danish) Currently translated at 57.8% (240 of 415 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 211 +++++++++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index e5f26aae..43da96b0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -93,4 +93,215 @@ Forhåndsvisning Profilindstillinger + Opsummering + Nyligt tilføjet i %1$s + Nyligt tilføjet + Nyligt optaget + Nyligt udkommet + Anbefalet + Optag program + Optag serie + Fjern fra favoritter + Genstart + Fortsæt + Gem + + Søg + Søger… + Stemmesøgning + Starter + Tal for at søge + Arbejder + Tryk tilbage for at afbryde + Fejl ved lydoptagelse + Fejl ved stemmegenkendelse + Mikrofontilladelse kræves + Netværksfejl + Netværkstimeout + Ingen tale genkendt + Stemmegenkendelse optaget + Serverfejl + Ingen tale registreret + Ukendt fejl + Kunne ikke starte stemmegenkendelse + Tidsgrænsen for stemmegenkendelse er overskredet + Prøv igen + Vælg server + Vælg bruger + Vis fejlfindingsoplysninger + Vis næste + Vis + Bland + Spring over + Dato tilføjet + Dato episode tilføjet + Dato afspillet + Udgivelsesdato + Navn + Tilfældig + Studier + Indsend + Downloaden tager lang tid, du skal muligvis genstarte afspilningen + Undertekst + Undertekster + Forslag + Skift servere + Skift + Bedst bedømte usete + Trailer + + Trailere + + + DVR-plan + TV-guide + Sæson + Sæsoner + + TV-serier + + + Grænseflade + Ukendt + Opdateringer + Version + Videoskalering + Video + Videoer + Se direkte + %1$d år gammel + Afspil med transkodning + Medieinformation + Vis ur + Udsendelsesrækkefølge + Opret ny playliste + Føj til playliste + Pause med ét klik + Tryk på midten af D-Pad\'en for at pause/afspille + Kursiv skrift + Skrifttype + Baggrund + Succes + Aldersgrænse + Spilletid + Ekstra + + Andet + + + + Bag kulisserne + + + + Temasange + + + + Temavideoer + + + + Klip + + + + Slettede scener + + + + Interview + + + + Scener + + + + Prøver + + + + Specialindslag + + + + Kortfilm + + + + %s download + %s downloads + + + %d time + %d timer + + + %s dag + %s dage + + + %d element + %d elementer + + + %d sekund + %d sekunder + + Enheden understøtter AC3/Dolby Digital + Avancerede indstillinger + Avanceret brugergrænseflade + Tema + Søg automatisk efter opdateringer + Forsinkelse før næste afspilning + Afspil næste automatisk + Søg efter opdateringer + Gælder kun for tv-serier + + Direkte afspilning af ASS-undertekster + Direkte afspilning af PGS-undertekster + Mix altid ned til stereo + Brug FFmpeg-dekodermodulet + Standard skalering + Installer opdatering + Installeret version + Maks antal elementer per række på startsiden + Vælg de standardelementer, der skal vises, andre vil blive skjult + Tilpas elementer i navigationsmenuen + Klik for at skifte side + Skift sider i navigationsmenuen ved fokus + Inaktivitetsbeskyttelse + Afspil temamusik + Tilsidesættelser af afspilning + Husk valgte faner + Tillad gensening i \"Næste\" + Trinlængde for søgelinje + Nyttig til fejlfinding + Send app-logfiler til den aktuelle server + Vil forsøge at sende til den sidst forbundne server + Send nedbrudsrapporter + Indstillinger + Spring tilbage, når afspilningen genoptages + Spring tilbage + Adfærd ved at springe reklamer over + Spring fremad + Adfærd ved at springe intro over + Adfærd ved at springe outro over + Adfærd ved at springe forhåndsvisninger over + Adfærd ved at springe opsummeringer over + Opdatering tilgængelig + URL brugt til at søge efter appopdateringer + URL til opdatering + Tekststørrelse + Tekstfarve + Tekstgennemsigtighed + Fed skrift + Kantstil + Kantfarve + Baggrundsopacitet + Baggrundsstil + Undertekststil + Baggrundsfarve + Nulstil From 2b7a1ecd4838cba1ba153c44f5c65b65fd15b466 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 20 Feb 2026 10:50:07 -0500 Subject: [PATCH 114/176] Fix linting --- app/src/main/res/values-fi/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + From 81133695469d811e6bfd371e50888c2352382230 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:07:36 -0500 Subject: [PATCH 115/176] Update readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index b4121cb9..1b3a0708 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin ### User interface +- Customize the home page to see the content you are interested in + - Use poster or thumb images, show/hide titles, add/remove/re-order different types of rows! - A navigation drawer for quick access to libraries, favorites, search, and settings from almost anywhere in the app - Integration with [Jellyseerr](https://github.com/seerr-team/seerr) to discover new movies and TV shows - Option to combine Continue Watching & Next Up rows @@ -112,6 +114,10 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ## Additional screenshots +### Customized home page +![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) + + ### Movie library browsing 0 3 0_movies From a8ab002dd1baf10ef02aad0e0816be7d361bbf35 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Fri, 20 Feb 2026 12:08:08 -0500 Subject: [PATCH 116/176] Release v0.5.0 From dfd9acf561b4f6779f22e5f49214657ec6e0fa3a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:28:57 -0500 Subject: [PATCH 117/176] Add metadata to media session (#934) ## Description Adds some metadata about playing media for consumption in remote control apps such as Google Home or others. ### Related issues Partially address #889, doesn't add explicit controls yet, but Google Home works ### Testing Nvidia shield + Google Home app ## Screenshots iOS Google Home app: ![ios_google_home](https://github.com/user-attachments/assets/680ccae5-b66e-404a-a3a1-7c89991d841b) ## AI or LLM usage None --- .../wholphin/services/ImageUrlService.kt | 3 ++- .../wholphin/ui/playback/CurrentMediaInfo.kt | 24 +++++++++++++++++++ .../wholphin/ui/playback/PlaybackViewModel.kt | 13 +++++++++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 3dfc24c1..f0de9d24 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -159,13 +159,14 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, + useSeriesForPrimary: Boolean? = null, ): String? = if (item != null) { getItemImageUrl( itemId = item.id, itemType = item.type, seriesId = item.data.seriesId, - useSeriesForPrimary = item.useSeriesForPrimary, + useSeriesForPrimary = useSeriesForPrimary ?: item.useSeriesForPrimary, imageTags = item.data.imageTags.orEmpty(), imageType = imageType, parentThumbId = item.data.parentThumbItemId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt index 2b7d61bc..e8dc7d1d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/CurrentMediaInfo.kt @@ -1,7 +1,12 @@ package com.github.damontecres.wholphin.ui.playback +import androidx.core.net.toUri +import androidx.media3.common.MediaMetadata +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.TrickplayInfo +import org.jellyfin.sdk.model.extensions.ticks data class CurrentMediaInfo( val sourceId: String?, @@ -15,3 +20,22 @@ data class CurrentMediaInfo( val EMPTY = CurrentMediaInfo(null, null, listOf(), listOf(), listOf(), null) } } + +fun BaseItem.toMediaMetadata(imageUrl: String?): MediaMetadata = + MediaMetadata + .Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setReleaseYear(data.productionYear) + .setDescription(data.overview) + .setArtworkUri(imageUrl?.toUri()) + .setDurationMs(data.runTimeTicks?.ticks?.inWholeMilliseconds) + .setMediaType( + when (type) { + BaseItemKind.MOVIE -> MediaMetadata.MEDIA_TYPE_MOVIE + BaseItemKind.EPISODE -> MediaMetadata.MEDIA_TYPE_TV_SHOW + BaseItemKind.VIDEO -> MediaMetadata.MEDIA_TYPE_VIDEO + BaseItemKind.TV_CHANNEL, BaseItemKind.CHANNEL, BaseItemKind.LIVE_TV_CHANNEL -> MediaMetadata.MEDIA_TYPE_TV_CHANNEL + else -> MediaMetadata.MEDIA_TYPE_VIDEO + }, + ).build() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index b3a446d7..4d44ff80 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -41,6 +41,7 @@ import com.github.damontecres.wholphin.preferences.SkipSegmentBehavior import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.DeviceProfileService +import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult @@ -95,6 +96,7 @@ import org.jellyfin.sdk.api.client.extensions.videosApi import org.jellyfin.sdk.api.sockets.subscribe import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.api.MediaSegmentDto import org.jellyfin.sdk.model.api.MediaSegmentType import org.jellyfin.sdk.model.api.MediaStreamType @@ -137,6 +139,7 @@ class PlaybackViewModel private val refreshRateService: RefreshRateService, val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -671,7 +674,15 @@ class PlaybackViewModel MediaItem .Builder() .setMediaId(itemId.toString()) - .setUri(mediaUrl.toUri()) + .setMediaMetadata( + item.toMediaMetadata( + imageUrlService.getItemImageUrl( + item, + ImageType.PRIMARY, + useSeriesForPrimary = true, + ), + ), + ).setUri(mediaUrl.toUri()) .setSubtitleConfigurations(listOfNotNull(externalSubtitle)) .apply { when (source.container) { From 4f8f69c438e9493aa608022e9e1f29d3daefc58e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 14:29:58 -0500 Subject: [PATCH 118/176] Better Jellyseerr connection error handling (#933) ## Description Show Jellyseerr connection errors in settings. If an error occurred trying to login, in setting it will show "Server error" and clicking gives more error info and option to remove the server. ### Related issues Fixes #907 ### Testing Emulator with simulated errors ## Screenshots N/A ## AI or LLM usage None --- .../services/SeerrServerRepository.kt | 89 +++++++++++-------- .../wholphin/ui/components/Dialogs.kt | 6 +- .../detail/discover/DiscoverMovieViewModel.kt | 3 +- .../wholphin/ui/discover/SeerrRequestsPage.kt | 2 +- .../ui/preferences/PreferencesContent.kt | 76 ++++++++++++---- .../ui/preferences/PreferencesViewModel.kt | 7 +- 6 files changed, 120 insertions(+), 63 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index c6da831f..78ff7d3b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -44,18 +44,33 @@ class SeerrServerRepository private val serverRepository: ServerRepository, @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { - private val _current = MutableStateFlow(null) - val current: StateFlow = _current - val currentServer: Flow = current.map { it?.server } - val currentUser: Flow = current.map { it?.user } + private val _connection = + MutableStateFlow(SeerrConnectionStatus.NotConfigured) + val connection: StateFlow = _connection + + val current: Flow = + _connection.map { (it as? SeerrConnectionStatus.Success)?.current } + val currentServer: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } + val currentUser: Flow = + connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } /** * Whether Seerr integration is currently active of not */ - val active: Flow = current.map { it != null && seerrApi.active } + val active: Flow = + connection.map { it is SeerrConnectionStatus.Success && seerrApi.active } fun clear() { - _current.update { null } + _connection.update { SeerrConnectionStatus.NotConfigured } + seerrApi.update("", null) + } + + fun error( + serverUrl: String, + exception: Exception, + ) { + _connection.update { SeerrConnectionStatus.Error(serverUrl, exception) } seerrApi.update("", null) } @@ -65,8 +80,10 @@ class SeerrServerRepository userConfig: SeerrUserConfig, ) { val publicSettings = seerrApi.api.settingsApi.settingsPublicGet() - _current.update { - CurrentSeerr(server, user, userConfig, publicSettings) + _connection.update { + SeerrConnectionStatus.Success( + CurrentSeerr(server, user, userConfig, publicSettings), + ) } } @@ -154,7 +171,7 @@ class SeerrServerRepository } suspend fun removeServer() { - val current = _current.value ?: return + val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) clear() } @@ -165,6 +182,19 @@ class SeerrServerRepository */ typealias SeerrUserConfig = User +sealed interface SeerrConnectionStatus { + data object NotConfigured : SeerrConnectionStatus + + data class Error( + val serverUrl: String, + val ex: Exception, + ) : SeerrConnectionStatus + + data class Success( + val current: CurrentSeerr, + ) : SeerrConnectionStatus +} + data class CurrentSeerr( val server: SeerrServer, val user: SeerrUser, @@ -244,45 +274,34 @@ class UserSwitchListener launchIO { seerrServerDao .getUsersByJellyfinUser(user.rowId) - .firstOrNull() + .lastOrNull() ?.let { seerrUser -> val server = seerrServerDao.getServer(seerrUser.serverId)?.server if (server != null) { Timber.i("Found a seerr user & server") - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - try { + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { login( seerrApi.api, seerrUser.authMethod, seerrUser.username, seerrUser.password, ) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.clear() - return@let - } - } else { - try { + } else { seerrApi.api.usersApi.authMeGet() - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.clear() - return@let } - } - seerrServerRepository.set(server, seerrUser, userConfig) + seerrServerRepository.set(server, seerrUser, userConfig) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server.url, ex) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index d5897a6b..579d9a97 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -408,12 +408,13 @@ fun ConfirmDialog( onConfirm: () -> Unit, properties: DialogProperties = DialogProperties(), elevation: Dp = 8.dp, + bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) = BasicDialog( onDismissRequest = onCancel, properties = properties, elevation = elevation, content = { - ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier) + ConfirmDialogContent(title, body, onCancel, onConfirm, Modifier, bodyColor) }, ) @@ -427,6 +428,7 @@ fun ConfirmDialogContent( onCancel: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, + bodyColor: Color = MaterialTheme.colorScheme.onSurface, ) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -446,7 +448,7 @@ fun ConfirmDialogContent( item { Text( text = body, - color = MaterialTheme.colorScheme.onSurface, + color = bodyColor, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index cc80d404..a50e848d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -73,7 +73,8 @@ class DiscoverMovieViewModel val canCancelRequest = MutableStateFlow(false) val userConfig = seerrServerRepository.current.map { it?.config } - val request4kEnabled = seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } + val request4kEnabled = + seerrServerRepository.current.map { it?.request4kMovieEnabled ?: false } init { init() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt index 3a838815..85863650 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrRequestsPage.kt @@ -64,7 +64,7 @@ class SeerrRequestsViewModel viewModelScope.launchIO { backdropService.clearBackdrop() } - seerrServerRepository.current + seerrServerRepository.connection .onEach { user -> state.update { it.copy(requests = DataLoadingState.Loading) } if (user != null) { 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 a806c31a..cb3856f6 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 @@ -51,6 +51,7 @@ 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.updatePlaybackPreferences +import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.ui.components.ConfirmDialog import com.github.damontecres.wholphin.ui.ifElse @@ -90,7 +91,7 @@ fun PreferencesContent( var showPinFlow by remember { mutableStateOf(false) } var cacheUsage by remember { mutableStateOf(CacheUsage(0, 0, 0)) } - val seerrIntegrationEnabled by viewModel.seerrEnabled.collectAsState(false) + val seerrConnection by viewModel.seerrConnection.collectAsState() var seerrDialogMode by remember { mutableStateOf(SeerrDialogMode.None) } var showQuickConnectDialog by remember { mutableStateOf(false) } @@ -385,19 +386,29 @@ fun PreferencesContent( ClickPreference( title = stringResource(pref.title), onClick = { - if (seerrIntegrationEnabled) { - seerrDialogMode = SeerrDialogMode.Remove - } else { - seerrVm.resetStatus() - seerrDialogMode = SeerrDialogMode.Add - } + seerrDialogMode = + when (val conn = seerrConnection) { + is SeerrConnectionStatus.Error -> { + SeerrDialogMode.Error(conn.serverUrl, conn.ex) + } + + SeerrConnectionStatus.NotConfigured -> { + SeerrDialogMode.Add + } + + is SeerrConnectionStatus.Success -> { + SeerrDialogMode.Remove( + conn.current.server.url, + ) + } + } }, modifier = Modifier, summary = - if (seerrIntegrationEnabled) { - stringResource(R.string.enabled) - } else { - null + when (seerrConnection) { + is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) + SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) + is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) }, onLongClick = {}, interactionSource = interactionSource, @@ -479,11 +490,11 @@ fun PreferencesContent( ) } } - when (seerrDialogMode) { - SeerrDialogMode.Remove -> { + when (val mode = seerrDialogMode) { + is SeerrDialogMode.Remove -> { ConfirmDialog( title = stringResource(R.string.remove_seerr_server), - body = currentServer?.url ?: "", + body = mode.serverUrl, onCancel = { seerrDialogMode = SeerrDialogMode.None }, onConfirm = { seerrVm.removeServer() @@ -510,6 +521,28 @@ fun PreferencesContent( ) } + is SeerrDialogMode.Error -> { + val errorStr = stringResource(R.string.voice_error_server) + val body = + remember(mode) { + """ + ${mode.serverUrl} + + $errorStr: ${mode.ex.localizedMessage} + """.trimIndent() + } + ConfirmDialog( + title = stringResource(R.string.remove_seerr_server), + body = body, + onCancel = { seerrDialogMode = SeerrDialogMode.None }, + onConfirm = { + seerrVm.removeServer() + seerrDialogMode = SeerrDialogMode.None + }, + bodyColor = MaterialTheme.colorScheme.error, + ) + } + SeerrDialogMode.None -> {} } } @@ -580,10 +613,17 @@ data class CacheUsage( val imageDiskUsed: Long, ) -private sealed class SeerrDialogMode { - data object None : SeerrDialogMode() +private sealed interface SeerrDialogMode { + data object None : SeerrDialogMode - data object Add : SeerrDialogMode() + data object Add : SeerrDialogMode - data object Remove : SeerrDialogMode() + data class Remove( + val serverUrl: String, + ) : SeerrDialogMode + + data class Error( + val serverUrl: String, + val ex: Exception, + ) : SeerrDialogMode } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index 709b6d9d..bec82e58 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin.ui.preferences import android.content.Context import androidx.datastore.core.DataStore import androidx.lifecycle.ViewModel -import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinUser @@ -22,7 +21,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.DeviceInfo @@ -46,10 +44,7 @@ class PreferencesViewModel RememberTabManager by rememberTabManager { val currentUser get() = serverRepository.currentUser - val seerrEnabled = - seerrServerRepository.currentUser.combine(currentUser.asFlow()) { seerrUser, jellyfinUser -> - seerrUser != null && jellyfinUser != null && seerrUser.jellyfinUserRowId == jellyfinUser.rowId - } + val seerrConnection = seerrServerRepository.connection private val _quickConnectStatus = MutableStateFlow(LoadingState.Pending) val quickConnectStatus: StateFlow = _quickConnectStatus From 7939fffd486246c0ba025076c6da2e82448dc8c1 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 20 Feb 2026 16:07:22 -0500 Subject: [PATCH 119/176] Override H264/AVC High 10 Profile Level 5.2 support for some devices (#935) ## Description Several Fire TV devices natively support H264/AVC High 10 Profile Level 5.2 according to [their specs](https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html), but the `MediaCodecInfo` doesn't return this support. So this PR adds an override for the known devices that support this which prevents unnecessary transcoding. ### Related issues Closes #786 ### Testing Verified on a Fire TV 4k Max 2nd Gen ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/util/profile/DeviceProfileUtils.kt | 11 +++++++++-- .../wholphin/util/profile/KnownDefects.kt | 17 ++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt index b98ececd..6272af5a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/DeviceProfileUtils.kt @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.util.profile // Adapted from https://github.com/jellyfin/jellyfin-androidtv/blob/v0.19.4/app/src/main/java/org/jellyfin/androidtv/util/profile/deviceProfile.kt +import android.media.MediaCodecInfo import androidx.media3.common.MimeTypes +import com.github.damontecres.wholphin.util.profile.KnownDefects.supportsHi10P52 import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.EncodingContext @@ -92,9 +94,14 @@ fun createDeviceProfile( val hevcMainLevel = mediaTest.getHevcMainLevel() val hevcMain10Level = mediaTest.getHevcMain10Level() val supportsAVC = mediaTest.supportsAVC() - val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() + val supportsAVCHigh10 = mediaTest.supportsAVCHigh10() || supportsHi10P52 val avcMainLevel = mediaTest.getAVCMainLevel() - val avcHigh10Level = mediaTest.getAVCHigh10Level() + val avcHigh10Level = + if (supportsHi10P52) { + MediaCodecInfo.CodecProfileLevel.AVCLevel52 + } else { + mediaTest.getAVCHigh10Level() + } val supportsAV1 = mediaTest.supportsAV1() val supportsAV1Main10 = mediaTest.supportsAV1Main10() val supportsVC1 = mediaTest.supportsVc1() diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt index 0a936de9..a4cd60c4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/profile/KnownDefects.kt @@ -5,7 +5,7 @@ package com.github.damontecres.wholphin.util.profile import android.os.Build /** - * List of devie models with known HEVC DoVi/HDR10+ playback issues. + * List of device models with known HEVC DoVi/HDR10+ playback issues. */ private val modelsWithDoViHdr10PlusBug = listOf( @@ -14,6 +14,21 @@ private val modelsWithDoViHdr10PlusBug = "AFTKM", // Amazon Fire TV 4K (2nd Gen) ) +/** + * List of device models that support H264 Hi10P 5.2, but don't advertise it + * + * Amazon devices from https://developer.amazon.com/docs/device-specs/device-specifications-fire-tv-streaming-media-player.html + */ +private val modelsWithHi10P52Support = + listOf( + "AFTMA08C15", // Fire TV Stick 4K Plus (2025) + "AFTKRT", // Amazon Fire TV 4K Max (2nd Gen) + "AFTKA", // Amazon Fire TV 4K Max (1st Gen) + "AFTKM", // Amazon Fire TV 4K (2nd Gen) + "AFTMM", // Fire TV Stick 4K - 1st Gen (2018) + ) + object KnownDefects { val hevcDoviHdr10PlusBug = Build.MODEL in modelsWithDoViHdr10PlusBug + val supportsHi10P52 = Build.MODEL in modelsWithHi10P52Support } From 55b148971ed004b9a281487b6a2f501684676d05 Mon Sep 17 00:00:00 2001 From: Cristiano Chelotti Date: Fri, 20 Feb 2026 19:24:35 -0500 Subject: [PATCH 120/176] Fixing Settings sticky header text overlapping with menu items (#928) ## Description - Adding a background for the Settings stickyHeader. This helps with visibility and prevents overlap with the menu items when scrolling. ### Related issues In the Settings screen, the header can overlap the text of the menu items when scrolling down. This is a minor UI bug only and does not affect functionality at all, but the fix seemed easy and harmless so I figured I'd give it a go. ### Testing Tested manually on TCL model 50S434 running Android TV. Tested manually using AVD emulation on Television (1080p) and Television (4K) preset devices. Reproduced the problem on all 3 devices and confirmed that my fix works across all. ## Screenshots Before: before-settings-fix After: after-settings-fix ## AI or LLM usage None --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../ui/preferences/PreferencesContent.kt | 576 +++++++++--------- 1 file changed, 290 insertions(+), 286 deletions(-) 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 cb3856f6..b04293f8 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 @@ -11,8 +11,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -166,308 +168,310 @@ fun PreferencesContent( LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } - LazyColumn( - state = state, - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(0.dp), - contentPadding = PaddingValues(16.dp), + Column( modifier = Modifier.background(MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)), ) { - stickyHeader { - Text( - text = stringResource(screenTitle), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - ) - } - if (UpdateChecker.ACTIVE && - preferenceScreenOption == PreferenceScreenOption.BASIC && - preferences.autoCheckForUpdates && - updateAvailable + Text( + text = stringResource(screenTitle), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + ) + LazyColumn( + state = state, + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(0.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.fillMaxSize(), ) { - item { - val updateFocusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { - if (focusedIndex.first == 0 && focusedIndex.second == 0) { - // Only re-focus if the user hasn't moved - updateFocusRequester.tryRequestFocus() - } - } - ClickPreference( - title = stringResource(R.string.install_update), - onClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = release?.version?.toString(), - modifier = - Modifier - .focusRequester(updateFocusRequester) - .playSoundOnFocus(movementSounds), - ) - } - } - 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 + if (UpdateChecker.ACTIVE && + preferenceScreenOption == PreferenceScreenOption.BASIC && + preferences.autoCheckForUpdates && + updateAvailable + ) { item { - val interactionSource = remember { MutableInteractionSource() } - val focused = interactionSource.collectIsFocusedAsState().value - LaunchedEffect(focused) { - if (focused) { - focusedIndex = Pair(groupIndex, prefIndex) - if (movementSounds) playOnClickSound(context) - onFocus.invoke(groupIndex, prefIndex) + val updateFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + if (focusedIndex.first == 0 && focusedIndex.second == 0) { + // Only re-focus if the user hasn't moved + updateFocusRequester.tryRequestFocus() } } - when (pref) { - AppPreference.InstalledVersion -> { - var clickCount by remember { mutableIntStateOf(0) } - ClickPreference( - title = stringResource(R.string.installed_version), - onClick = { - if (movementSounds) playOnClickSound(context) - if (clickCount++ >= 2) { - clickCount = 0 - viewModel.navigationManager.navigateTo(Destination.Debug) - } - }, - summary = installedVersion.toString(), - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + ClickPreference( + title = stringResource(R.string.install_update), + onClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + }, + summary = release?.version?.toString(), + modifier = + Modifier + .focusRequester(updateFocusRequester) + .playSoundOnFocus(movementSounds), + ) + } + } + 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) + if (movementSounds) playOnClickSound(context) + onFocus.invoke(groupIndex, prefIndex) + } } - - AppPreference.Update -> { - ClickPreference( - title = - if (release != null && updateAvailable) { - stringResource(R.string.install_update) - } else if (!preferences.autoCheckForUpdates && release == null) { - stringResource(R.string.check_for_updates) - } else { - stringResource(R.string.no_update_available) + when (pref) { + AppPreference.InstalledVersion -> { + var clickCount by remember { mutableIntStateOf(0) } + ClickPreference( + title = stringResource(R.string.installed_version), + onClick = { + if (movementSounds) playOnClickSound(context) + if (clickCount++ >= 2) { + clickCount = 0 + viewModel.navigationManager.navigateTo(Destination.Debug) + } }, - onClick = { - if (movementSounds) playOnClickSound(context) - if (release != null && updateAvailable) { - release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) + summary = installedVersion.toString(), + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } + + AppPreference.Update -> { + ClickPreference( + title = + if (release != null && updateAvailable) { + stringResource(R.string.install_update) + } else if (!preferences.autoCheckForUpdates && release == null) { + stringResource(R.string.check_for_updates) + } else { + stringResource(R.string.no_update_available) + }, + onClick = { + if (movementSounds) playOnClickSound(context) + if (release != null && updateAvailable) { + release?.let { + viewModel.navigationManager.navigateTo(Destination.UpdateApp) + } + } else { + updateVM.init(preferences.updateUrl) } - } else { - updateVM.init(preferences.updateUrl) - } - }, - onLongClick = { - if (movementSounds) playOnClickSound(context) - viewModel.navigationManager.navigateTo(Destination.UpdateApp) - }, - summary = - if (updateAvailable) { - release?.version?.toString() - } else { - null }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) - } - - AppPreference.ClearImageCache -> { - val summary = - remember(cacheUsage) { - cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT - val memoryUsedMB = - it.imageMemoryUsed / AppPreference.MEGA_BIT - val memoryMaxMB = - it.imageMemoryMax / AppPreference.MEGA_BIT - "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" - } - } - ClickPreference( - title = stringResource(pref.title), - onClick = { - SingletonImageLoader.get(context).let { - it.memoryCache?.clear() - it.diskCache?.clear() - updateCache = true - } - }, - modifier = Modifier, - summary = summary, - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.UserPinnedNavDrawerItems -> { - NavDrawerPreference( - title = stringResource(pref.title), - summary = pref.summary(context, null), - modifier = Modifier, - interactionSource = interactionSource, - ) - } - - AppPreference.SendAppLogs -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.sendAppLogs() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - SubtitleSettings.Reset -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - viewModel.resetSubtitleSettings() - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - interactionSource = interactionSource, - ) - } - - AppPreference.RequireProfilePin -> { - SwitchPreference( - title = stringResource(pref.title), - value = currentUser?.pin.isNotNullOrBlank(), - onClick = { - showPinFlow = true - }, - summaryOn = stringResource(R.string.enabled), - summaryOff = null, - modifier = Modifier, - ) - } - - AppPreference.SeerrIntegration -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - seerrDialogMode = - when (val conn = seerrConnection) { - is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error(conn.serverUrl, conn.ex) - } - - SeerrConnectionStatus.NotConfigured -> { - SeerrDialogMode.Add - } - - is SeerrConnectionStatus.Success -> { - SeerrDialogMode.Remove( - conn.current.server.url, - ) - } - } - }, - modifier = Modifier, - summary = - when (seerrConnection) { - is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) - SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) - is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + onLongClick = { + if (movementSounds) playOnClickSound(context) + viewModel.navigationManager.navigateTo(Destination.UpdateApp) }, - onLongClick = {}, - interactionSource = interactionSource, - ) - } + summary = + if (updateAvailable) { + release?.version?.toString() + } else { + null + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } - AppPreference.QuickConnect -> { - ClickPreference( - title = stringResource(pref.title), - onClick = { - if (currentUser != null) { - viewModel.resetQuickConnectStatus() - showQuickConnectDialog = true - } - }, - modifier = Modifier, - summary = pref.summary(context, null), - onLongClick = {}, - 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() + AppPreference.ClearImageCache -> { + val summary = + remember(cacheUsage) { + cacheUsage.let { + val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val memoryUsedMB = + it.imageMemoryUsed / AppPreference.MEGA_BIT + val memoryMaxMB = + it.imageMemoryMax / AppPreference.MEGA_BIT + "Disk: ${diskMB}mb, Memory: ${memoryUsedMB}mb/${memoryMaxMB}mb" } + } + ClickPreference( + title = stringResource(pref.title), + onClick = { + SingletonImageLoader.get(context).let { + it.memoryCache?.clear() + it.diskCache?.clear() + updateCache = true + } + }, + modifier = Modifier, + summary = summary, + onLongClick = {}, + interactionSource = interactionSource, + ) + } - PreferenceValidation.Valid -> { - scope.launch(ExceptionHandler()) { - preferences = - viewModel.preferenceDataStore.updateData { prefs -> - pref.setter(prefs, newValue) - } + AppPreference.UserPinnedNavDrawerItems -> { + NavDrawerPreference( + title = stringResource(pref.title), + summary = pref.summary(context, null), + modifier = Modifier, + interactionSource = interactionSource, + ) + } + + AppPreference.SendAppLogs -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.sendAppLogs() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + SubtitleSettings.Reset -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.resetSubtitleSettings() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.RequireProfilePin -> { + SwitchPreference( + title = stringResource(pref.title), + value = currentUser?.pin.isNotNullOrBlank(), + onClick = { + showPinFlow = true + }, + summaryOn = stringResource(R.string.enabled), + summaryOff = null, + modifier = Modifier, + ) + } + + AppPreference.SeerrIntegration -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + seerrDialogMode = + when (val conn = seerrConnection) { + is SeerrConnectionStatus.Error -> { + SeerrDialogMode.Error(conn.serverUrl, conn.ex) + } + + SeerrConnectionStatus.NotConfigured -> { + SeerrDialogMode.Add + } + + is SeerrConnectionStatus.Success -> { + SeerrDialogMode.Remove( + conn.current.server.url, + ) + } + } + }, + modifier = Modifier, + summary = + when (seerrConnection) { + is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) + SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) + is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + }, + onLongClick = {}, + interactionSource = interactionSource, + ) + } + + AppPreference.QuickConnect -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + if (currentUser != null) { + viewModel.resetQuickConnectStatus() + showQuickConnectDialog = true + } + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + 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()) { + preferences = + viewModel.preferenceDataStore.updateData { prefs -> + pref.setter(prefs, newValue) + } + } } } - } - }, - interactionSource = interactionSource, - modifier = - Modifier - .ifElse( - groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, - Modifier.focusRequester(focusRequester), - ), - ) + }, + interactionSource = interactionSource, + modifier = + Modifier + .ifElse( + groupIndex == focusedIndex.first && prefIndex == focusedIndex.second, + Modifier.focusRequester(focusRequester), + ), + ) + } } } } From cc052453a4ab1a1b2ae91570df831ef61e486862 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sat, 21 Feb 2026 15:30:16 -0500 Subject: [PATCH 121/176] Add basic music video support (#948) ## Description Just enables showing and playing music videos as if they were regular videos. Basic metadata is shown as well. Navigation isn't great because a music video library goes by folders which don't necessary have the best UI choices. ### Related issues Related to #267 ### Testing Checked playback on the emulator ## Screenshots N/A ## AI or LLM usage None --- .../github/damontecres/wholphin/ui/nav/DestinationContent.kt | 4 +++- .../java/com/github/damontecres/wholphin/util/Constants.kt | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 eccfd6de..0888e721 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 @@ -121,7 +121,9 @@ fun DestinationContent( ) } - BaseItemKind.VIDEO -> { + BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, + -> { // TODO Use VideoDetails MovieDetails( preferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt index da246f73..fa0476cb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/Constants.kt @@ -9,6 +9,7 @@ val supportItemKinds = BaseItemKind.EPISODE, BaseItemKind.SERIES, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.SEASON, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.FOLDER, @@ -47,6 +48,7 @@ val supportedPlayableTypes = BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, + BaseItemKind.MUSIC_VIDEO, BaseItemKind.TV_CHANNEL, BaseItemKind.TV_PROGRAM, BaseItemKind.RECORDING, From e8c9b4e208feae611672acfee0d906996078d70c Mon Sep 17 00:00:00 2001 From: YogiBear12 <139140546+YogiBear12@users.noreply.github.com> Date: Mon, 23 Feb 2026 02:27:05 +1100 Subject: [PATCH 122/176] SeriesOverview Alignment (#930) ## Description This PR changes the padding on the SeriesOverview to align it with the updated layout on the Home, MovieDetails and SeriesDetails pages. There is also a small change to fix an incorrect spacing on the SeriesDetails, and start padding added to the ItemRow to bring it into alignment with other text elements on the page and ensure consistent spacing from the navdrawer ### Related issues I believe this is the final PR to close out [704](https://github.com/damontecres/Wholphin/discussions/704) ### Testing Tested changes on both the emulator and physical tv ## Screenshots Home screen showing the ItemRow alignment home SeriesOverview Screenshot_20260221_200450 ## AI or LLM usage AI was used to identify the current padding and spacing values --- .../com/github/damontecres/wholphin/ui/cards/ItemRow.kt | 3 ++- .../wholphin/ui/detail/series/FocusedEpisodeHeader.kt | 7 ++++--- .../wholphin/ui/detail/series/SeriesDetails.kt | 2 +- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 9 +++++---- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt index 7ebf0faf..314a7e1a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/ItemRow.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -57,7 +58,7 @@ fun ItemRow( text = title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) LazyRow( state = state, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index ff0ddade..aab6e056 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.ui.detail.series import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusState @@ -31,17 +32,17 @@ fun FocusedEpisodeHeader( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { - EpisodeName(dto, modifier = Modifier) + EpisodeName(dto, modifier = Modifier.padding(start = 8.dp)) ep?.ui?.quickDetails?.let { - QuickDetails(it, ep.timeRemainingOrRuntime) + QuickDetails(it, ep.timeRemainingOrRuntime, Modifier.padding(start = 8.dp)) } if (dto != null) { VideoStreamDetails( chosenStreams = chosenStreams, numberOfVersions = dto.mediaSourceCount ?: 0, - modifier = Modifier, + modifier = Modifier.padding(start = 8.dp), ) } OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index f0c74be3..579a3866 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -621,7 +621,7 @@ fun SeriesDetailsHeader( ) { QuickDetails(series.ui.quickDetails, null, Modifier.padding(start = 8.dp)) dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = 8.dp, bottom = 12.dp)) + GenreText(it, Modifier.padding(start = 8.dp, bottom = 8.dp)) } dto.overview?.let { overview -> OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 86e2b398..114454c1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -134,7 +134,7 @@ fun SeriesOverviewContent( .onFocusChanged { pageHasFocus = it.hasFocus }, ) { Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .focusGroup() @@ -159,9 +159,10 @@ fun SeriesOverviewContent( Modifier .focusRequester(tabRowFocusRequester) .padding(paddingValues) + .padding(bottom = 4.dp) .fillMaxWidth(), ) - SeriesName(series.name, Modifier) + SeriesName(series.name, Modifier.padding(start = 8.dp)) FocusedEpisodeHeader( preferences = preferences, ep = focusedEpisode, @@ -294,8 +295,8 @@ fun SeriesOverviewContent( }, modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), + .padding(top = 4.dp) + .fillMaxWidth(), ) } } From a51d9e9c65ec1f93674c3e0b085135e5d4018b91 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Sun, 22 Feb 2026 20:18:18 -0500 Subject: [PATCH 123/176] Fix clicking genre cards on home page (#953) ## Description Fixes clicking on a genre card added to the home page ### Related issues Fixes #952 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 28 ++++++++++++++++ .../wholphin/services/HomeSettingsService.kt | 33 ++++++++++++++----- .../wholphin/ui/components/GenreCardGrid.kt | 27 ++++----------- .../wholphin/ui/playback/PlaybackConstants.kt | 18 ++++++++++ 4 files changed, 78 insertions(+), 28 deletions(-) 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 172c6ef5..950e9b40 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 @@ -25,6 +25,7 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.extensions.ticks import java.util.Locale +import java.util.UUID import kotlin.time.Duration @Serializable @@ -33,6 +34,7 @@ data class BaseItem( val data: BaseItemDto, val useSeriesForPrimary: Boolean, val imageUrlOverride: String? = null, + val destinationOverride: Destination? = null, ) : CardGridItem { val id get() = data.id @@ -166,6 +168,7 @@ data class BaseItem( }?.toIntOrNull() fun destination(index: Int? = null): Destination { + if (destinationOverride != null) return destinationOverride val result = // Redirect episodes & seasons to their series if possible when (type) { @@ -234,3 +237,28 @@ data class BaseItemUi( val episodeUnplayedCornerText: String?, val quickDetails: AnnotatedString, ) + +fun createGenreDestination( + genreId: UUID, + genreName: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List?, +) = Destination.FilteredCollection( + itemId = parentId, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + genreName, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + genres = listOf(genreId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 93dc32d2..2fe5a6cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -7,6 +7,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -14,6 +15,7 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.components.getGenreImageMap import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.main.settings.favoriteOptions +import com.github.damontecres.wholphin.ui.playback.getTypeFor import com.github.damontecres.wholphin.ui.toBaseItems import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -654,18 +656,33 @@ class HomeSettingsService includeItemTypes = null, cardWidthPx = null, ) - val genres = - items.map { - BaseItem(it, false, genreImages[it.id]) - } - - val name = + val library = libraries .firstOrNull { it.itemId == row.parentId } - ?.name + val title = - name?.let { context.getString(R.string.genres_in, it) } + library?.name?.let { context.getString(R.string.genres_in, it) } ?: context.getString(R.string.genres) + val genres = + items.map { + BaseItem( + it, + false, + genreImages[it.id], + createGenreDestination( + genreId = it.id, + genreName = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } Success( title, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 84f36f02..e64f1824 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -21,8 +21,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.data.model.CollectionFolderFilter -import com.github.damontecres.wholphin.data.model.GetItemsFilter +import com.github.damontecres.wholphin.data.model.createGenreDestination import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect @@ -30,7 +29,6 @@ import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGridItem -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.GetGenresRequestHandler @@ -256,23 +254,12 @@ fun GenreCardGrid( pager = genres, onClickItem = { _, genre -> viewModel.navigationManager.navigateTo( - Destination.FilteredCollection( - itemId = itemId, - filter = - CollectionFolderFilter( - nameOverride = - listOfNotNull( - genre.name, - item?.title, - ).joinToString(" "), - filter = - GetItemsFilter( - genres = listOf(genre.id), - includeItemTypes = includeItemTypes, - ), - useSavedLibraryDisplayInfo = false, - ), - recursive = true, + createGenreDestination( + genreId = genre.id, + genreName = genre.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, ), ) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index 55fd5424..fa56bb84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.CollectionType val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.75", "2.0") @@ -81,3 +82,20 @@ val BaseItemKind.playable: Boolean BaseItemKind.YEAR, -> false } + +fun getTypeFor(collectionType: CollectionType): BaseItemKind? = + when (collectionType) { + CollectionType.UNKNOWN -> null + CollectionType.MOVIES -> BaseItemKind.MOVIE + CollectionType.TVSHOWS -> BaseItemKind.SERIES + CollectionType.MUSIC -> BaseItemKind.AUDIO + CollectionType.MUSICVIDEOS -> BaseItemKind.MUSIC_VIDEO + CollectionType.TRAILERS -> BaseItemKind.TRAILER + CollectionType.HOMEVIDEOS -> BaseItemKind.VIDEO + CollectionType.BOXSETS -> BaseItemKind.BOX_SET + CollectionType.BOOKS -> BaseItemKind.BOOK + CollectionType.PHOTOS -> BaseItemKind.PHOTO_ALBUM + CollectionType.LIVETV -> BaseItemKind.LIVE_TV_CHANNEL + CollectionType.PLAYLISTS -> BaseItemKind.PLAYLIST + CollectionType.FOLDERS -> BaseItemKind.FOLDER + } From a44fad890f13285a4618397da5523162166f91fd Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 09:56:22 -0500 Subject: [PATCH 124/176] Add preset for home page episode thumbnails (#954) ## Description Adds a preset for "Episode thumbnails" which shows episodes using their Primary image (ie a thumbnail or screenshot of the episode). The previous "Thumbnails" preset is renamed "Series Thumbs" because it uses the episode's Series Thumb image. Also fixes a bug where toggling "Use series" might not update the home page preview automatically. ### Related issues Fixes #940 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/cards/BannerCard.kt | 6 +- .../damontecres/wholphin/ui/main/HomePage.kt | 2 + .../ui/main/settings/HomeRowPresets.kt | 69 +++++++++++++++---- app/src/main/res/values/strings.xml | 4 ++ 4 files changed, 66 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt index 294df8f6..51d003fe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/BannerCard.kt @@ -68,6 +68,7 @@ fun BannerCard( interactionSource: MutableInteractionSource? = null, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = true, ) { val imageUrlService = LocalImageUrlService.current val density = LocalDensity.current @@ -82,7 +83,7 @@ fun BannerCard( } } val imageUrl = - remember(item, fillHeight, imageType) { + remember(item, fillHeight, imageType, useSeriesForPrimary) { if (item != null) { item.imageUrlOverride ?: imageUrlService.getItemImageUrl( @@ -90,6 +91,7 @@ fun BannerCard( imageType, fillWidth = null, fillHeight = fillHeight, + useSeriesForPrimary = useSeriesForPrimary, ) } else { null @@ -208,6 +210,7 @@ fun BannerCardWithTitle( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, imageType: ImageType = ImageType.PRIMARY, imageContentScale: ContentScale = ContentScale.FillBounds, + useSeriesForPrimary: Boolean = item?.useSeriesForPrimary ?: true, ) { val focused by interactionSource.collectIsFocusedAsState() val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) @@ -234,6 +237,7 @@ fun BannerCardWithTitle( interactionSource = interactionSource, imageType = imageType, imageContentScale = imageContentScale, + useSeriesForPrimary = useSeriesForPrimary, ) Column( verticalArrangement = Arrangement.spacedBy(0.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index b9a7cbd7..cf8738c5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -524,6 +524,7 @@ fun HomePageCardContent( onLongClick = onLongClick, modifier = modifier, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } else { BannerCard( @@ -543,6 +544,7 @@ fun HomePageCardContent( modifier = modifier, interactionSource = null, cardHeight = viewOptions.heightDp.dp, + useSeriesForPrimary = viewOptions.useSeries, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index ff4cfe0a..815c343a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -123,7 +123,7 @@ data class HomeRowPresets( ) } - val Thumbnails by lazy { + val SeriesThumbs by lazy { val height = 148 val epHeight = 100 HomeRowPresets( @@ -132,6 +132,7 @@ data class HomeRowPresets( heightDp = epHeight, imageType = ViewOptionImageType.THUMB, aspectRatio = AspectRatio.WIDE, + useSeries = true, episodeImageType = ViewOptionImageType.THUMB, episodeAspectRatio = AspectRatio.WIDE, ), @@ -164,6 +165,50 @@ data class HomeRowPresets( genreSize = epHeight, ) } + + val EpisodeThumbnails by lazy { + val height = 148 + val epHeight = 100 + HomeRowPresets( + continueWatching = + HomeRowViewOptions( + heightDp = epHeight, + imageType = ViewOptionImageType.THUMB, + aspectRatio = AspectRatio.WIDE, + showTitles = true, + useSeries = false, + episodeImageType = ViewOptionImageType.PRIMARY, + episodeAspectRatio = AspectRatio.WIDE, + ), + movieLibrary = + HomeRowViewOptions( + heightDp = height, + ), + tvLibrary = + HomeRowViewOptions( + heightDp = height, + ), + videoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + ), + photoLibrary = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.WIDE, + contentScale = PrefContentScale.CROP, + ), + playlist = + HomeRowViewOptions( + heightDp = epHeight, + aspectRatio = AspectRatio.SQUARE, + contentScale = PrefContentScale.FIT, + ), + liveTv = HomeRowViewOptions.liveTvDefault, + genreSize = epHeight, + ) + } } } @@ -173,13 +218,13 @@ fun HomeRowPresetsContent( modifier: Modifier = Modifier, ) { val presets = - remember { - listOf( - "Wholphin Default", - "Wholphin Compact", - "Thumbnails", - ) - } + listOf( + stringResource(R.string.display_preset_default) to HomeRowPresets.WholphinDefault, + stringResource(R.string.display_preset_compact) to HomeRowPresets.WholphinCompact, + stringResource(R.string.display_preset_series_thumb) to HomeRowPresets.SeriesThumbs, + stringResource(R.string.display_preset_episode_thumbnails) to HomeRowPresets.EpisodeThumbnails, + ) + val focusRequesters = remember { List(presets.size) { FocusRequester() } } LaunchedEffect(Unit) { focusRequesters[0].tryRequestFocus() } Column(modifier = modifier) { @@ -192,16 +237,12 @@ fun HomeRowPresetsContent( .fillMaxHeight() .focusRestorer(focusRequesters[0]), ) { - itemsIndexed(presets) { index, title -> + itemsIndexed(presets) { index, (title, preset) -> HomeSettingsListItem( selected = false, headlineText = title, onClick = { - when (index) { - 0 -> onApply.invoke(HomeRowPresets.WholphinDefault) - 1 -> onApply.invoke(HomeRowPresets.WholphinCompact) - 2 -> onApply.invoke(HomeRowPresets.Thumbnails) - } + onApply.invoke(preset) }, modifier = Modifier.focusRequester(focusRequesters[index]), ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4f3db4f..3baee433 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,6 +522,10 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Wholphin Default + Wholphin Compact + Series Thumb images + Episode Thumbnail images Disabled From 2a371e7d6bb3d3021459c1585bdb00915b368d5e Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 11:17:57 -0500 Subject: [PATCH 125/176] Fix strings missing from translations (#959) ## Description Weblate's Android string parser (aka https://translate.codeberg.org/projects/wholphin/) doesn't support direct translations of string arrays. Many of Wholphin's preference options are defined in string arrays and therefore could not be translated. So this PR implements the [suggested workaround](https://docs.weblate.org/en/latest/formats/android.html) to move the string into individual entries and reference them in arrays. Also moves some hardcoded strings into `strings.xml` so they can be translated. Note: virtually all error messages are still hardcoded, but they are only for exceptional cases, so it's probably not a huge problem. A minor bug where the backdrop would show briefly after switching users is fixed. ### Related issues Fixes #957 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 6 + .../damontecres/wholphin/ui/Formatting.kt | 3 +- .../ui/components/CollectionFolderGrid.kt | 2 +- .../ui/playback/DownloadSubtitlesDialog.kt | 6 +- .../wholphin/ui/playback/PlaybackConstants.kt | 13 +- .../wholphin/ui/playback/PlaybackDialog.kt | 6 +- .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 6 +- .../ui/setup/seerr/AddSeerrServerDialog.kt | 41 ++--- app/src/main/res/values/strings.xml | 166 ++++++++++++------ 9 files changed, 156 insertions(+), 93 deletions(-) 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 34bd871c..b7f4b1d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -123,6 +123,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var suggestionsSchedulerService: SuggestionsSchedulerService + @Inject + lateinit var backdropService: BackdropService + // Note: unused but injected to ensure it is created @Inject lateinit var serverEventListener: ServerEventListener @@ -247,6 +250,9 @@ class MainActivity : AppCompatActivity() { } is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } val current = key.current ProvideLocalClock { if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 1842620a..8f4066bb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSegmentType import timber.log.Timber @@ -76,7 +77,7 @@ val BaseItemDto.seriesProductionYears: String? append(productionYear.toString()) if (status == "Continuing") { append(" - ") - append("Present") + append(WholphinApplication.instance.getString(R.string.series_continueing)) } else if (status == "Ended") { endDate?.let { if (it.year != productionYear) { 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 4c672998..9110802d 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 @@ -857,7 +857,7 @@ fun CollectionFolderGridContent( DataLoadingState.Loading, -> { // This shouldn't happen, so just show placeholder - Text("Loading") + Text(stringResource(R.string.loading)) } is DataLoadingState.Error -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt index b3813ed5..e1a291e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/DownloadSubtitlesDialog.kt @@ -102,7 +102,7 @@ fun DownloadSubtitlesContent( .padding(PaddingValues(24.dp)), ) { Text( - text = "Search & download subtitles", + text = stringResource(R.string.search_and_download_subtitles), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) @@ -113,7 +113,7 @@ fun DownloadSubtitlesContent( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Language", + text = stringResource(R.string.language), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, ) @@ -137,7 +137,7 @@ fun DownloadSubtitlesContent( } if (dialogItems.isEmpty()) { Text( - text = "No remote subtitles were found", + text = stringResource(R.string.no_subtitles_found), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt index fa56bb84..474c7932 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackConstants.kt @@ -1,6 +1,7 @@ package com.github.damontecres.wholphin.ui.playback import androidx.compose.ui.layout.ContentScale +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.preferences.PrefContentScale import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType @@ -9,13 +10,13 @@ val playbackSpeedOptions = listOf(".25", ".5", ".75", "1.0", "1.25", "1.5", "1.7 val playbackScaleOptions = mapOf( - ContentScale.Fit to "Fit", - ContentScale.None to "None", - ContentScale.Crop to "Crop", + ContentScale.Fit to R.string.content_scale_fit, + ContentScale.None to R.string.none, + ContentScale.Crop to R.string.content_scale_crop, // ContentScale.Inside to "Inside", - ContentScale.FillBounds to "Fill", - ContentScale.FillWidth to "Fill Width", - ContentScale.FillHeight to "Fill Height", + ContentScale.FillBounds to R.string.content_scale_fill, + ContentScale.FillWidth to R.string.content_scale_fill_width, + ContentScale.FillHeight to R.string.content_scale_fill_height, ) val PrefContentScale.scale: ContentScale diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt index 04ff620a..c6d7b622 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackDialog.kt @@ -144,7 +144,9 @@ fun PlaybackDialog( BottomDialogItem( data = PlaybackDialogType.VIDEO_SCALE, headline = stringResource(R.string.video_scale), - supporting = playbackScaleOptions[settings.contentScale], + supporting = + playbackScaleOptions[settings.contentScale] + ?.let { stringResource(it) }, ), ) } @@ -220,7 +222,7 @@ fun PlaybackDialog( playbackScaleOptions.map { (scale, name) -> BottomDialogItem( data = scale, - headline = name, + headline = stringResource(name), supporting = null, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index d2b48c35..81e06fb8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -62,7 +62,7 @@ fun AddSeerrServerApiKey( val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } Text( - text = "Enter URL & API Key", + text = stringResource(R.string.enter_url_api_key), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, @@ -73,7 +73,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "URL", + text = stringResource(R.string.url), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( @@ -104,7 +104,7 @@ fun AddSeerrServerApiKey( modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( - text = "API Key", + text = stringResource(R.string.api_key), modifier = Modifier.padding(end = 8.dp), ) EditTextBox( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 3601fdd7..9566b88b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog import com.github.damontecres.wholphin.ui.components.DialogItem @@ -71,27 +73,26 @@ fun ChooseSeerrLoginType( onChoose: (SeerrAuthMethod) -> Unit, ) { val params = - remember { - DialogParams( - fromLongClick = false, - title = "Login to Seerr server", - items = - listOf( - DialogItem( - text = "API Key", - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), - DialogItem( - text = "Jellyfin user", - onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, - ), - DialogItem( - text = "Local user", - onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, - ), + DialogParams( + fromLongClick = false, + title = stringResource(R.string.seerr_login), + items = + listOf( + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, ), - ) - } + DialogItem( + text = stringResource(R.string.seerr_jellyfin_user), + onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, + ), + DialogItem( + text = stringResource(R.string.seerr_local_user), + onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, + ), + ), + ) + DialogPopup( params = params, onDismissRequest = onDismissRequest, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3baee433..2967ada1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,109 +522,161 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Fit + Crop + Fill + Fill width + Fill height Wholphin Default Wholphin Compact Series Thumb images Episode Thumbnail images + Lowest + Low + Medium + High + Full - Disabled - Lowest - Low - Medium - High - Full + @string/disabled + @string/volume_lowest + @string/volume_low + @string/volume_medium + @string/volume_high + @string/volume_full + Purple + Orange + Bold Blue + Black - Purple - Blue - Green - Orange - Bold Blue - Black + @string/purple + @string/blue + @string/green + @string/orange + @string/bold_blue + @string/black + Ignore + Skip automatically + Ask to skip - Ignore - Skip automatically - Ask to skip + @string/skip_ignore + @string/skip_automatically + @string/skip_ask - Fit - None - Crop - Fill - Fill Width - Fill Height + @string/content_scale_fit + @string/none + @string/content_scale_crop + @string/content_scale_fill + @string/content_scale_fill_width + @string/content_scale_fill_height + Only use FFmpeg if no built-in decoder exists + Prefer to use FFmpeg over built-in decoders + Never use FFmpeg decoders - Only use FFmpeg if no built-in decoder exists - Prefer to use FFmpeg over built-in decoders - Never use FFmpeg decoders + @string/ffmpeg_fallback + @string/ffmpeg_prefer + @string/ffmpeg_never + At the end of playback + During end credits/outro - At the end of playback - During end credits/outro + @string/next_up_playback_end + @string/next_up_outro + White + Light gray + Dark gray + Yellow + Cyan + Magenta - White - Black - Light Gray - Dark Gray - Red - Yellow - Green - Cyan - Blue - Magenta + @string/white + @string/black + @string/light_gray + @string/dark_gray + @string/red + @string/yellow + @string/green + @string/cyan + @string/blue + @string/magenta + Outline + Shadow - None - Outline - Shadow + @string/none + @string/subtitle_edge_outline + @string/subtitle_edge_shadow + Wrap + Boxed - None - Wrap - Boxed + @string/none + @string/background_style_wrap + @string/background_style_boxed + ExoPlayer + MPV + Prefer MPV - ExoPlayer - MPV - Prefer MPV + @string/exoplayer + @string/mpv + @string/prefer_mpv + Use ExoPlayer for HDR playback - - - Use ExoPlayer for HDR playback + + + @string/player_backend_options_subtitles_prefer_mpv + Poster (2:3) + 16:9 + 4:3 + Square (1:1) - Poster (2:3) - 16:9 - 4:3 - Square (1:1) + @string/aspect_ratios_poster + @string/aspect_ratios_16_9 + @string/aspect_ratios_4_3 + @string/aspect_ratios_square + Primary + Thumbary - Primary - Thumb + @string/image_type_primary + @string/image_type_thumb + Image with dynamic color + Image only + + API Key + Login to Seerr server + Jellyfin user + Local user + + No remote subtitles were found + Present - Image with dynamic color - Image only - None + @string/backdrop_style_dynamic + @string/backdrop_style_image + @string/none From e1c66dfb1d8dc7644afa091b9881eba697460fa7 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Mon, 23 Feb 2026 12:34:46 -0500 Subject: [PATCH 126/176] Fix series overview episode images --- .../wholphin/ui/detail/series/SeriesOverviewContent.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index 114454c1..a0d120e1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -267,6 +267,7 @@ fun SeriesOverviewContent( }, interactionSource = interactionSource, cardHeight = 120.dp, + useSeriesForPrimary = false, ) } } From 3b162a2f98f6b53023fdfad84bdacc63504db217 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:38:02 -0500 Subject: [PATCH 127/176] Play theme songs for collections (#962) ## Description Play theme songs for collections if available ### Related issues Closes #711 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 105 ++++++++++++------ .../ui/detail/CollectionFolderPhotoAlbum.kt | 2 +- app/src/main/res/values/strings.xml | 2 +- 3 files changed, 73 insertions(+), 36 deletions(-) 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 9110802d..c5dc912d 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.viewModelScope import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text @@ -60,6 +61,8 @@ import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ThemeSongPlayer +import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -120,7 +123,9 @@ class CollectionFolderViewModel private val libraryDisplayInfoDao: LibraryDisplayInfoDao, private val favoriteWatchManager: FavoriteWatchManager, private val backdropService: BackdropService, - val navigationManager: NavigationManager, + private val navigationManager: NavigationManager, + private val themeSongPlayer: ThemeSongPlayer, + private val userPreferencesService: UserPreferencesService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -157,9 +162,10 @@ class CollectionFolderViewModel viewModelScope.launchIO { super.itemId = itemId try { - itemId.toUUIDOrNull()?.let { - fetchItem(it) - } + val item = + itemId.toUUIDOrNull()?.let { + fetchItem(it) + } val libraryDisplayInfo = serverRepository.currentUser.value?.let { user -> @@ -184,6 +190,8 @@ class CollectionFolderViewModel } loadResults(true, sortAndDirection, recursive, filterToUse, useSeriesForPrimary) + .join() +// onResumePage() } catch (ex: Exception) { Timber.e(ex, "Error during init") loading.setValueOnMain(DataLoadingState.Error(ex)) @@ -254,34 +262,32 @@ class CollectionFolderViewModel recursive: Boolean, filter: GetItemsFilter, useSeriesForPrimary: Boolean, - ) { - viewModelScope.launch(Dispatchers.IO) { - withContext(Dispatchers.Main) { - if (resetState) { - loading.value = DataLoadingState.Loading - } - backgroundLoading.value = LoadingState.Loading - this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection - this@CollectionFolderViewModel.filter.value = filter + ) = viewModelScope.launch(Dispatchers.IO) { + withContext(Dispatchers.Main) { + if (resetState) { + loading.value = DataLoadingState.Loading } - try { - val newPager = - createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() - if (newPager.isNotEmpty()) newPager.getBlocking(0) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Success(newPager) - backgroundLoading.value = LoadingState.Success - } - } catch (ex: Exception) { - Timber.e( - ex, - "Exception while loading data: sort=%s, filter=%s", - sortAndDirection, - filter, - ) - withContext(Dispatchers.Main) { - loading.value = DataLoadingState.Error(ex) - } + backgroundLoading.value = LoadingState.Loading + this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection + this@CollectionFolderViewModel.filter.value = filter + } + try { + val newPager = + createPager(sortAndDirection, recursive, filter, useSeriesForPrimary).init() + if (newPager.isNotEmpty()) newPager.getBlocking(0) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Success(newPager) + backgroundLoading.value = LoadingState.Success + } + } catch (ex: Exception) { + Timber.e( + ex, + "Exception while loading data: sort=%s, filter=%s", + sortAndDirection, + filter, + ) + withContext(Dispatchers.Main) { + loading.value = DataLoadingState.Error(ex) } } } @@ -443,6 +449,30 @@ class CollectionFolderViewModel backdropService.submit(item) } } + + fun navigateTo(destination: Destination) { + release() + navigationManager.navigateTo(destination) + } + + fun release() { + themeSongPlayer.stop() + } + + fun onResumePage() { + viewModelScope.launchIO { + item.value?.let { + Timber.v("onResumePage: %s", loading.value!!::class) + if (it.type == BaseItemKind.BOX_SET && loading.value !is DataLoadingState.Error) { + val volume = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.playThemeSongs + themeSongPlayer.playThemeFor(it.id, volume) + } + } + } + } } /** @@ -548,6 +578,13 @@ fun CollectionFolderGrid( ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) Box(modifier = modifier) { + LifecycleResumeEffect(itemId) { + viewModel.onResumePage() + + onPauseOrDispose { + viewModel.release() + } + } CollectionFolderGridContent( preferences = preferences, initialPosition = viewModel.position, @@ -598,7 +635,7 @@ fun CollectionFolderGrid( } else { Destination.Playback(item) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, onClickPlayAll = { shuffle -> itemId.toUUIDOrNull()?.let { @@ -622,7 +659,7 @@ fun CollectionFolderGrid( filter = filter, ) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) } }, ) @@ -660,7 +697,7 @@ fun CollectionFolderGrid( favorite = item.favorite, actions = MoreDialogActions( - navigateTo = { viewModel.navigationManager.navigateTo(it) }, + navigateTo = { viewModel.navigateTo(it) }, onClickWatch = { itemId, watched -> viewModel.setWatched(position, itemId, watched) }, 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 index fd4d46d3..4ddc41c1 100644 --- 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 @@ -65,7 +65,7 @@ fun CollectionFolderPhotoAlbum( } else { item.destination(index) } - viewModel.navigationManager.navigateTo(destination) + viewModel.navigateTo(destination) }, itemId = itemId.toServerString(), initialFilter = filter, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2967ada1..93476b74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -656,7 +656,7 @@ Primary - Thumbary + Thumb @string/image_type_primary @string/image_type_thumb From 348a3022d6420a8ed4b821d9bdd07a17c4298607 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:20:25 -0500 Subject: [PATCH 128/176] Fix cut off cards on grids with show details enabled (#963) ## Description Fixes the previous row of cards showing the bottom cut off when the "Show details" view option is enabled. ### Related issues Fixes #404 Fixes #877 Related to #882 ### Testing Emulator ## Screenshots ![grid_show_details Large](https://github.com/user-attachments/assets/3376275e-261e-4e5a-b874-18be7606a9bf) ## AI or LLM usage None --- .../ui/components/CollectionFolderGrid.kt | 20 ++- .../wholphin/ui/detail/CardGrid.kt | 166 +++++++++--------- 2 files changed, 104 insertions(+), 82 deletions(-) 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 c5dc912d..4a71404a 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 @@ -6,7 +6,9 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -33,6 +35,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -84,6 +87,7 @@ import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils +import com.github.damontecres.wholphin.ui.util.ScrollToTopBringIntoViewSpec import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler @@ -735,6 +739,7 @@ fun CollectionFolderGrid( } } +@OptIn(ExperimentalFoundationApi::class) @Composable fun CollectionFolderGridContent( preferences: UserPreferences, @@ -879,14 +884,16 @@ fun CollectionFolderGridContent( } } } + val defaultBringIntoViewSpec = LocalBringIntoViewSpec.current + val density = LocalDensity.current AnimatedVisibility(viewOptions.showDetails) { HomePageHeader( item = focusedItem, modifier = Modifier .fillMaxWidth() - .height(140.dp) - .padding(16.dp), + .height(200.dp) + .padding(top = 48.dp, bottom = 32.dp, start = 8.dp), ) } when (val state = loadingState) { @@ -932,6 +939,15 @@ fun CollectionFolderGridContent( }, columns = viewOptions.columns, spacing = viewOptions.spacing.dp, + bringIntoViewSpec = + remember(viewOptions) { + val spacingPx = with(density) { viewOptions.spacing.dp.toPx() } + if (viewOptions.showDetails) { + ScrollToTopBringIntoViewSpec(spacingPx) + } else { + defaultBringIntoViewSpec + } + }, ) AnimatedVisibility(showViewOptions) { ViewOptionsDialog( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index 8893246c..ac72a144 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -4,6 +4,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.gestures.BringIntoViewSpec +import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement @@ -24,6 +26,7 @@ import androidx.compose.foundation.lazy.layout.LazyLayoutCacheWindow import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -112,6 +115,7 @@ fun CardGrid( }, columns: Int = 6, spacing: Dp = 16.dp, + bringIntoViewSpec: BringIntoViewSpec = LocalBringIntoViewSpec.current, ) { val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0)) @@ -269,100 +273,102 @@ fun CardGrid( Box( modifier = Modifier.weight(1f), ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing), - state = gridState, - contentPadding = PaddingValues(vertical = 16.dp), - modifier = - Modifier - .fillMaxSize() - .focusGroup() - .focusRestorer(firstFocus) - .focusProperties { - onExit = { - // Leaving the grid, so "forget" the position + CompositionLocalProvider(LocalBringIntoViewSpec provides bringIntoViewSpec) { + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + horizontalArrangement = Arrangement.spacedBy(spacing), + verticalArrangement = Arrangement.spacedBy(spacing), + state = gridState, + contentPadding = PaddingValues(vertical = 16.dp), + modifier = + Modifier + .fillMaxSize() + .focusGroup() + .focusRestorer(firstFocus) + .focusProperties { + onExit = { + // Leaving the grid, so "forget" the position // focusedIndex = -1 - } - onEnter = { - if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { - focusedIndex = startPosition } - } - }, - ) { - items(pager.size) { index -> - val mod = - if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { - if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") - Modifier - .focusRequester(firstFocus) - .focusRequester(gridFocusRequester) - .focusRequester(alphabetFocusRequester) - } else { - Modifier - } - val item = pager[index] - cardContent( - item, - { - if (item != null) { - focusedIndex = index - onClickItem.invoke(index, item) - } - }, - { if (item != null) onLongClickItem.invoke(index, item) }, - mod - .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) - .onFocusChanged { focusState -> - if (DEBUG) { - Timber.v( - "$index isFocused=${focusState.isFocused}", - ) + onEnter = { + if (focusedIndex < 0 && gridState.firstVisibleItemIndex <= startPosition) { + focusedIndex = startPosition + } } - if (focusState.isFocused) { - // Focused, so set that up - focusOn(index) - positionCallback?.invoke(columns, index) - } else if (focusedIndex == index) { + }, + ) { + items(pager.size) { index -> + val mod = + if ((index == focusedIndex) or (focusedIndex < 0 && index == 0)) { + if (DEBUG) Timber.d("Adding firstFocus to focusedIndex $index") + Modifier + .focusRequester(firstFocus) + .focusRequester(gridFocusRequester) + .focusRequester(alphabetFocusRequester) + } else { + Modifier + } + val item = pager[index] + cardContent( + item, + { + if (item != null) { + focusedIndex = index + onClickItem.invoke(index, item) + } + }, + { if (item != null) onLongClickItem.invoke(index, item) }, + mod + .ifElse(index == 0, Modifier.focusRequester(zeroFocus)) + .onFocusChanged { focusState -> + if (DEBUG) { + Timber.v( + "$index isFocused=${focusState.isFocused}", + ) + } + if (focusState.isFocused) { + // Focused, so set that up + focusOn(index) + positionCallback?.invoke(columns, index) + } else if (focusedIndex == index) { // savedFocusedIndex = index // // Was focused on this, so mark unfocused // focusedIndex = -1 - } - }, - ) + } + }, + ) + } } - } - if (pager.isEmpty()) { + if (pager.isEmpty()) { // focusedIndex = -1 - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(R.string.no_results), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.align(Alignment.Center), - ) + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(R.string.no_results), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.align(Alignment.Center), + ) + } } - } - if (showFooter) { - // Footer - Box( - modifier = - Modifier - .align(Alignment.BottomCenter) - .background(AppColors.TransparentBlack50), - ) { - val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" + if (showFooter) { + // Footer + Box( + modifier = + Modifier + .align(Alignment.BottomCenter) + .background(AppColors.TransparentBlack50), + ) { + val index = (focusedIndex + 1).takeIf { it > 0 } ?: "?" // if (focusedIndex >= 0) { // focusedIndex + 1 // } else { // max(savedFocusedIndex, focusedIndexOnExit) + 1 // } - Text( - modifier = Modifier.padding(4.dp), - color = MaterialTheme.colorScheme.onBackground, - text = "$index / ${pager.size}", - ) + Text( + modifier = Modifier.padding(4.dp), + color = MaterialTheme.colorScheme.onBackground, + text = "$index / ${pager.size}", + ) + } } } } From d483ebf7354f800da2a053417ad7acf62f3f9918 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:31 -0500 Subject: [PATCH 129/176] Update Gradle to v9.3.1 (#869) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Update | Change | |---|---|---| | [gradle](https://gradle.org) ([source](https://redirect.github.com/gradle/gradle)) | minor | `9.2.1` → `9.3.1` | --- ### Release Notes
gradle/gradle (gradle) ### [`v9.3.1`](https://redirect.github.com/gradle/gradle/releases/tag/v9.3.1): 9.3.1 [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.3.0...v9.3.1) This is a patch release for 9.3.0. We recommend using 9.3.1 instead of 9.3.0. The following issues were resolved: - [Cannot find testcases from Android Screenshot Test plugin since Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36320) - [Excluding dependencies from included builds doesn't work in Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36331) - [ExternalDependency and DependencyConstraint cannot be passed to DependencyResolveDetails#useTarget](https://redirect.github.com/gradle/gradle/issues/36359) - [Gradle 9.3.0 generate JUnit test result files with wrong name](https://redirect.github.com/gradle/gradle/issues/36379) - [Build cache cannot handle outputs with non-BMP characters in the filename](https://redirect.github.com/gradle/gradle/issues/36387) - [Emojis in test names should not break build caching](https://redirect.github.com/gradle/gradle/issues/36395) - [Non utf-8 c code is no longer buildable](https://redirect.github.com/gradle/gradle/issues/36399) - [Breaking change in 9.3.0 regarding cross-project dependency manipulation](https://redirect.github.com/gradle/gradle/issues/36428) - [JUnit3 tests cannot be run with Gradle 9.3.0](https://redirect.github.com/gradle/gradle/issues/36451) - [Test.setScanForTestClasses(false) causes all junit4 tests to be skipped](https://redirect.github.com/gradle/gradle/issues/36508) [Read the Release Notes](https://docs.gradle.org/9.3.1/release-notes.html) #### Upgrade instructions Switch your build to use Gradle 9.3.1 by updating your wrapper: ``` ./gradlew wrapper --gradle-version=9.3.1 && ./gradlew wrapper ``` See the Gradle [9.x upgrade guide](https://docs.gradle.org/9.3.1/userguide/upgrading_version_9.html) to learn about deprecations, breaking changes and other considerations when upgrading. For Java, Groovy, Kotlin and Android compatibility, see the [full compatibility notes](https://docs.gradle.org/9.3.1/userguide/compatibility.html). #### Reporting problems If you find a problem with this release, please file a bug on [GitHub Issues](https://redirect.github.com/gradle/gradle/issues) adhering to our issue guidelines. If you're not sure you're encountering a bug, please use the [forum](https://discuss.gradle.org/c/help-discuss). We hope you will build happiness with Gradle, and we look forward to your feedback via [Twitter](https://twitter.com/gradle) or on [GitHub](https://redirect.github.com/gradle). ### [`v9.3.0`](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0) [Compare Source](https://redirect.github.com/gradle/gradle/compare/v9.2.1...v9.3.0)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.jar | Bin 43764 -> 46175 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 5 +---- gradlew.bat | 3 +-- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55baabb587c669f562ae36f953de2481846..61285a659d17295f1de7c53e24fdf13ad755c379 100644 GIT binary patch delta 37988 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 xt3G?g z4&W%tBqXPbR`Dfgu>2G0TRV&4bOw6_Oz;EyI?i0{oODJ_4RFbbY2RF|&r|=*#sMY0 z^C#;!#u8O8tq#gKuO^NKL!6Empu_>FK3#2qJJ>Gg2Xel_lYOijF0X4d*)|59)BO|T zbURajp;K0G60wrdr5zug$aA=!F{BTlUpz*+?~5|qnzM^2-)J~`dY|bTblUUB2E0zZ zTVU9xOlwKgGs`C~SzmLe70MsFd5R%^#gG5Fe3Ew+Y@7c|KK8HV)<8aT1VdaD@53hs zK3VM$cX*5nGAbswuZ9B6S5JK1%{5s~{ABm!0TL{o--m-y_e#*5;Gks<%U4#kR=7EL zfdL<|duXWGB1Xo2BtemLawd&4`z5*w2gbEEKX_nZG}cRNIdHbz1`hQ=nDsa0xI&=n84=S@gGA2DHTxQF0jjouQnFsD*`p#;*Xg)EAP?cklFQb>(sA} zqNk`Su_t$9u&Lr6nlrh_xaFqd&PVrLKB?HbeLk9Nmy1i)h$6BN$+5&R?!ns!q3|_` zIU3m-`iV13Iwu;dUqV*uj0CvF&z5e_ryG5NqxHN$QUepAY&@Sf0-)FUqLKGPE?Tuybs0 zflp2+u!wi|uhII!bB}KtElo6FEaxecon!{PqX04^gX-k2woe$JhG1$>RKotzO>vVX zf~U5Pf+n937U@14ClFNVgE3AL>-48h;3<)wwUITF_%(1WH zt`@dMMY1U6RpwZhRTz1f@oPD?J~KglvhXauZl_U5!YyO@N!e(v>YEx#ue9$_$}Klc zs*8wd-HSapLJ!kDq;u0txcz@oOi2^~qFdeV`n?vl9v&LL=}oqoP8OqVAI@`b-wuJV z#+?@iA-7*ULLx$N1cjJ#h|QcqZoFJLn_I{uu?x*pMmvmx_kgMF0znV&_ztnBr@!8p zUC?2~#v)1@;PrS~$vt15qCoU8teD&L%Pq%N$EZFxAGBC8hc`FVXvTO(Jo_M1oy+eA z^_7k=J!_a^M?dmq@x)*>Kzt@6Y;3xou%6JGW&y8B> z%%tcqpf79fPUvikJUbYLNldGFu*~+sGnDd&gPW10*$Bj5mAH|82V>xVA~7jHbf_8) zkrU#%C=m-jZC@4f5pIxXQAfE2o*puTv=@LPB{+ngnBZA~wL)Sn@ezgnuokGZkd2Xp+j2&bAixl>UdhtWT=nGFR8N_Zz(q8aI!vtcZ;N^| z_PMlgO?9N@79w`#!HdJ_Bwwt`%JX-w>Wr?i5{#LOhtl6{jm^1C9Oaw(Ts4^QQJGmt z?>sWdDdptyT>&Adp*tjGx)@ko6sv(%+W1Jaia(KUfv12MZ&K9|@NE-I_&1qmt`**Am7|)DZk7<}0lEb(bp|OCIpv*7ETM(?Lz-@t>PU0~)D1BEIs_YYB&Z7Oh6Mb&2M1vC4_z5qw(S9VpM z(nw04WrEHJBmi=6!AM5Kz2u>-^?$<^=|x*Z=SbD^Quu;#a7yW-gd-TEj7IIvbGYQY zQ8Vby5K2EmJ!6U^gGiQ|bZ3v_9_J&t&?p*X@#E{>6R_(qR}F=yq_Ior>zBGeUfW8H~Sw?Z6`>8E+J9iP_v1=!S{< z7cL7;5UE+f;AR)G+fPuRb7yyAU!d8}<3ABR2{@VN{c~_q!3x1*kqkaMK6X6r?3z1<@E1h5xo+)q%j zQeUw}SQ(M@cv>Ykax~-it9ui1yu#%7$Qpo;opj*sy1)IXM>x}9{$ZmYTBW#%;qVqi zhZibvl2%4P>LkNfDwL&iLfuZ3WSr5XiNO嵍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 delta 35498 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 Date: Mon, 23 Feb 2026 18:00:42 -0500 Subject: [PATCH 130/176] Update Dependencies to v2.59.2 (#936) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger:hilt-android-compiler](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android-compiler/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android-compiler/2.59.1/2.59.2?slim=true) | | [com.google.dagger:hilt-android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger:hilt-android/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger:hilt-android/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 05bf860b..2c2234f0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,7 +34,7 @@ protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" protobuf-javalite = "4.33.5" -hilt = "2.59.1" +hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" tvprovider = "1.1.0" From 790bb4b535fa2216d459d8dc75d9013bc6611957 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:00:53 -0500 Subject: [PATCH 131/176] Update dependency com.google.dagger.hilt.android to v2.59.2 (#937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [com.google.dagger.hilt.android](https://redirect.github.com/google/dagger) | `2.59.1` → `2.59.2` | ![age](https://developer.mend.io/api/mc/badges/age/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/com.google.dagger.hilt.android:com.google.dagger.hilt.android.gradle.plugin/2.59.1/2.59.2?slim=true) | --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From 3e2a1869ab3d84f6d1e6d31e5210fbba0bf9a5c0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 18:52:38 -0500 Subject: [PATCH 132/176] Scroll to current chapter on playback overlay (#964) ## Description This PR makes it so when you open the playback overlay and move down to the "Chapters" row, the first focused chapter will be the current one instead of always the first one. ### Related issues Closes #695 Closes #951 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/Chapter.kt | 3 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 56 ++++++++++++++++--- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt index 1bcacb3f..3444ac54 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Chapter.kt @@ -31,6 +31,7 @@ data class Chapter( ) }, ) - }.orEmpty() + }?.sortedBy { it.position } + .orEmpty() } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index 2b311e71..b1500b60 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -24,6 +24,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged @@ -254,8 +258,34 @@ fun PlaybackOverlay( exit = slideOutVertically { it / 2 } + fadeOut(), ) { if (chapters.isNotEmpty()) { + val bringIntoViewRequester = remember { BringIntoViewRequester() } + val chapterIndex = + remember { + val position = playerControls.currentPosition.milliseconds + val index = + chapters + .indexOfFirst { it.position > position } + .minus(1) + .let { + if (it < 0) { + // Didn't find a chapter, so it's either the first or last + if (position < chapters.first().position) { + 0 + } else { + chapters.lastIndex + } + } else { + it + } + }.coerceIn(0, chapters.lastIndex) + index + } + val listState = rememberLazyListState(chapterIndex) val focusRequester = remember { FocusRequester() } - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + LaunchedEffect(Unit) { + bringIntoViewRequester.bringIntoView() + focusRequester.tryRequestFocus() + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = @@ -276,6 +306,7 @@ fun PlaybackOverlay( style = MaterialTheme.typography.titleLarge, ) LazyRow( + state = listState, contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = @@ -305,10 +336,19 @@ fun PlaybackOverlay( }, interactionSource = interactionSource, modifier = - Modifier.ifElse( - index == 0, - Modifier.focusRequester(focusRequester), - ), + Modifier + .ifElse( + index == chapterIndex, + Modifier + .focusRequester(focusRequester) + .bringIntoViewRequester(bringIntoViewRequester), + ).ifElse( + index == 0, + Modifier.focusProperties { + // Prevent scrolling left on first card to prevent moving down + left = FocusRequester.Cancel + }, + ), ) } } @@ -448,8 +488,10 @@ fun PlaybackOverlay( style = MaterialTheme.typography.labelLarge, modifier = Modifier - .background(Color.Black.copy(alpha = 0.6f), shape = RoundedCornerShape(4.dp)) - .padding(horizontal = 8.dp, vertical = 4.dp), + .background( + Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(4.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), ) } } From 7424f812d8510add906b4f3a86727f687b4a733c Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 23 Feb 2026 22:04:08 -0500 Subject: [PATCH 133/176] Dev: add CI to build app store releases (#468) Adding GHA workflows to build the app store releases Also cleans up gradle build script a bit --- .github/workflows/pr.yml | 27 ++++---- .github/workflows/release.yml | 30 +++++++-- app/build.gradle.kts | 103 ++++++++++++++----------------- app/src/patches/play_store.patch | 40 ++++++++++++ 4 files changed, 123 insertions(+), 77 deletions(-) create mode 100644 app/src/patches/play_store.patch diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index db31b32e..b4489867 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -44,19 +44,18 @@ jobs: id: buildapp run: | ./gradlew clean assembleDebug testDebugUnitTest --no-daemon - apks=$(find app/build/outputs/apk -name '*.apk' -print0 | tr '\0' ',' | sed 's/,$//') + apks=$(find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | tr '\0' ',' | sed 's/,$//') echo "apks=$apks" >> "$GITHUB_OUTPUT" - - name: Tar build dirs + + test-patch: + runs-on: ubuntu-latest + needs: pre-commit + steps: + - name: Checkout the code + uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: Test applying patch run: | - tar -czf build.tgz ./app/. - - uses: actions/upload-artifact@v6 - id: upload-build-dirs - with: - name: "${{ env.BUILD_DIRS_ARTIFACT }}" - path: build.tgz - if-no-files-found: error - - uses: actions/upload-artifact@v6 - with: - name: APKs - path: "${{ steps.buildapp.outputs.apks }}" - compression-level: 0 + git apply app/src/patches/play_store.patch + git diff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b63017b5..86c17c4e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,11 +39,24 @@ jobs: SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" run: | ./gradlew clean assembleRelease --no-daemon + + - name: Build app + id: buildaab + env: + KEY_ALIAS: "${{ secrets.KEY_ALIAS }}" + KEY_PASSWORD: "${{ secrets.KEY_PASSWORD }}" + KEY_STORE_PASSWORD: "${{ secrets.KEY_STORE_PASSWORD }}" + SIGNING_KEY: "${{ secrets.SIGNING_KEY }}" + run: | + git apply app/src/patches/play_store.patch + ./gradlew bundleRelease --no-daemon + aab=$(find app/build/outputs -name '*.aab') + echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | - echo "Verify APK signatures" - find app/build/outputs/apk -name '*.apk' - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + echo "Verify APK/AAB signatures" + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | @@ -59,11 +72,18 @@ jobs: - name: Checksums run: | echo "SHA256 checksums:" - find app/build/outputs/apk -name '*.apk' -print0 | xargs -0 sha256sum + find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum + - name: Upload AAB + uses: actions/upload-artifact@v6 + with: + name: AAB + path: | + app/build/outputs/bundle/**/*.aab + compression-level: 0 - name: Create GitHub release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh release create "${{ env.TAG_NAME }}" \ - --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "Placeholder" --draft \ + --latest --title "${{ env.TAG_NAME }}" --verify-tag -n "" --draft \ "app/build/outputs/apk/**/*.apk" diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ab4a9cc..366dfcd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -47,20 +47,64 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + if (shouldSign) { + create("ci") { + file("ci.keystore").writeBytes( + Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), + ) + keyAlias = System.getenv("KEY_ALIAS") + keyPassword = System.getenv("KEY_PASSWORD") + storePassword = System.getenv("KEY_STORE_PASSWORD") + storeFile = file("ci.keystore") + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } + } + buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", ) isDebuggable = false + if (shouldSign) { + signingConfig = signingConfigs.getByName("ci") + } else { + val localPropertiesFile = project.rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + val properties = Properties() + properties.load(localPropertiesFile.inputStream()) + val signingConfigName = properties["release.signing.config"]?.toString() + if (signingConfigName != null) { + signingConfig = signingConfigs.getByName(signingConfigName) + } + } + } } + debug { isMinifyEnabled = false isDebuggable = true applicationIdSuffix = ".debug" } + + applicationVariants.all { + val variant = this + variant.outputs + .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } + .forEach { output -> + val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } + val outputFileName = + "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" + output.outputFileName = outputFileName + } + } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 @@ -81,63 +125,6 @@ android { room { schemaDirectory("$projectDir/schemas") } - signingConfigs { - if (shouldSign) { - create("ci") { - file("ci.keystore").writeBytes( - Base64.getDecoder().decode(System.getenv("SIGNING_KEY")), - ) - keyAlias = System.getenv("KEY_ALIAS") - keyPassword = System.getenv("KEY_PASSWORD") - storePassword = System.getenv("KEY_STORE_PASSWORD") - storeFile = file("ci.keystore") - enableV1Signing = true - enableV2Signing = true - enableV3Signing = true - enableV4Signing = true - } - } - } - buildTypes { - release { - isMinifyEnabled = false - - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } else { - val localPropertiesFile = project.rootProject.file("local.properties") - if (localPropertiesFile.exists()) { - val properties = Properties() - properties.load(localPropertiesFile.inputStream()) - val signingConfigName = properties["release.signing.config"]?.toString() - if (signingConfigName != null) { - signingConfig = signingConfigs.getByName(signingConfigName) - } - } - } - } - debug { - if (shouldSign) { - signingConfig = signingConfigs.getByName("ci") - } - } - - applicationVariants.all { - val variant = this - variant.outputs - .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } - .forEach { output -> - val abi = output.getFilter("ABI").let { if (it != null) "-$it" else "" } - val outputFileName = - "Wholphin-${variant.baseName}-${variant.versionName}-${variant.versionCode}$abi.apk" - output.outputFileName = outputFileName - } - } - } splits { abi { diff --git a/app/src/patches/play_store.patch b/app/src/patches/play_store.patch new file mode 100644 index 00000000..ef5fb96d --- /dev/null +++ b/app/src/patches/play_store.patch @@ -0,0 +1,40 @@ +commit f82fb1a2d8ff9917a7bdb0bc7101a0474359ccdc +Author: Damontecres +Date: Sat Nov 22 13:00:55 2025 -0500 + + Setup for play store + +diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml +index 6d84299..12576af 100644 +--- a/app/src/main/AndroidManifest.xml ++++ b/app/src/main/AndroidManifest.xml +@@ -4,7 +4,6 @@ + + + +- + +@@ -17,7 +16,7 @@ + android:required="false" /> + ++ android:required="true" /> + +diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +index c7ac435..fa42fe1 100644 +--- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt ++++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +@@ -62,7 +62,7 @@ class UpdateChecker + + private val NOTE_REGEX = Regex("") + +- val ACTIVE = true ++ val ACTIVE = false + } + + suspend fun maybeShowUpdateToast( From c4cd1fbfd3ee9f53feb6b4811c61e35b78580eeb Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 10:24:26 -0500 Subject: [PATCH 134/176] Update actions/checkout action to v6 (#965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/checkout](https://redirect.github.com/actions/checkout) | action | major | `v5` → `v6` | --- ### Release Notes
actions/checkout (actions/checkout) ### [`v6`](https://redirect.github.com/actions/checkout/compare/v5...v6) [Compare Source](https://redirect.github.com/actions/checkout/compare/v5...v6)
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b4489867..0b0f2204 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,7 +52,7 @@ jobs: needs: pre-commit steps: - name: Checkout the code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 1 - name: Test applying patch From a14fdac85232b3f4a5f2e8b319461f02031fdf65 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 24 Feb 2026 12:37:21 -0500 Subject: [PATCH 135/176] A few more home page fixes (#967) ## Description A few more home page fixes * Focus issue between settings & preset buttons * Fix default genre card size for "Wholphin Default" preset * Cache genre image urls for 2 hours so that they don't change on every refresh ### Related issues Related to #399 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/HomeSettingsService.kt | 3 +++ .../wholphin/ui/components/GenreCardGrid.kt | 26 +++++++++++++++++++ .../wholphin/ui/main/HomeViewModel.kt | 3 +++ .../ui/main/settings/HomeRowPresets.kt | 3 +-- .../ui/main/settings/HomeSettingsRowList.kt | 2 +- .../ui/main/settings/HomeSettingsViewModel.kt | 1 + 6 files changed, 35 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 2fe5a6cc..b8a850ef 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -569,6 +569,7 @@ class HomeSettingsService userDto: UserDto, libraries: List, limit: Int = prefs.maxItemsPerRow, + isRefresh: Boolean, ): HomeRowLoadingState = when (row) { is HomeRowConfig.ContinueWatching -> { @@ -649,12 +650,14 @@ class HomeSettingsService val genreImages = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = scope, imageUrlService = imageUrlService, genres = genreIds, parentId = row.parentId, includeItemTypes = null, cardWidthPx = null, + useCache = isRefresh, ) val library = libraries diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e64f1824..e1e28f0c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingState +import com.mayakapps.kache.InMemoryKache import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -55,8 +56,10 @@ import org.jellyfin.sdk.model.api.ItemFields import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.request.GetGenresRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.hours @HiltViewModel(assistedFactory = GenreViewModel.Factory::class) class GenreViewModel @@ -110,6 +113,7 @@ class GenreViewModel val genreToUrl = getGenreImageMap( api = api, + userId = serverRepository.currentUser.value?.id, scope = viewModelScope, imageUrlService = imageUrlService, genres = genres.map { it.id }, @@ -141,15 +145,35 @@ class GenreViewModel } } +data class GenreCacheKey( + val userId: UUID?, + val parentId: UUID, +) + +private val genreCache by lazy { + InMemoryKache>(8) { + expireAfterWriteDuration = 2.hours + } +} + suspend fun getGenreImageMap( api: ApiClient, + userId: UUID?, scope: CoroutineScope, imageUrlService: ImageUrlService, genres: List, parentId: UUID, includeItemTypes: List?, cardWidthPx: Int?, + useCache: Boolean = true, ): Map { + val key = GenreCacheKey(userId, parentId) + if (useCache) { + genreCache.getIfAvailable(key)?.let { + Timber.v("Got cached entry") + return it + } + } val genreToUrl = ConcurrentHashMap() val semaphore = Semaphore(4) genres @@ -161,6 +185,7 @@ suspend fun getGenreImageMap( .execute( api, GetItemsRequest( + userId = userId, parentId = parentId, recursive = true, limit = 1, @@ -189,6 +214,7 @@ suspend fun getGenreImageMap( } } }.awaitAll() + genreCache.put(key, genreToUrl) return genreToUrl } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index 4471194e..a8ce7fd9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -78,6 +78,8 @@ class HomeViewModel // Refreshing if a load has already occurred and the rows haven't significantly changed val refresh = state.loadingState == LoadingState.Success && state.settings == settings + Timber.v("refresh=$refresh, state.loadingState=${state.loadingState}") + _state.update { it.copy(settings = settings) } val semaphore = Semaphore(4) @@ -102,6 +104,7 @@ class HomeViewModel userDto = userDto, libraries = libraries, limit = prefs.maxItemsPerRow, + isRefresh = refresh, ) } catch (ex: Exception) { Timber.e(ex, "Error on row %s", row) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt index 815c343a..eaf44d81 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeRowPresets.kt @@ -19,7 +19,6 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.preferences.PrefContentScale import com.github.damontecres.wholphin.ui.AspectRatio -import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.components.ViewOptionImageType import com.github.damontecres.wholphin.ui.tryRequestFocus import org.jellyfin.sdk.model.api.CollectionType @@ -81,7 +80,7 @@ data class HomeRowPresets( contentScale = PrefContentScale.FIT, ), liveTv = HomeRowViewOptions.liveTvDefault, - genreSize = Cards.HEIGHT_2X3_DP, + genreSize = HomeRowViewOptions.genreDefault.heightDp, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt index 5a0ac965..9eadc615 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsRowList.kt @@ -137,7 +137,7 @@ fun HomeSettingsRowList( position = 2 onClickPresets.invoke() }, - modifier = Modifier.focusRequester(focusRequesters[1]), + modifier = Modifier.focusRequester(focusRequesters[2]), ) } item { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index 9110af12..67b57e28 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -133,6 +133,7 @@ class HomeSettingsViewModel userDto = userDto, libraries = state.libraries, limit = limit, + isRefresh = false, ) } } From 89bdae9cd78d1c2e56a0f38ff073608e85934342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Priit=20J=C3=B5er=C3=BC=C3=BCt?= Date: Fri, 20 Feb 2026 14:34:52 +0000 Subject: [PATCH 136/176] Translated using Weblate (Estonian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/et/ --- app/src/main/res/values-et/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index d4d5d17f..10b611eb 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -446,8 +446,8 @@ Rakenda kõikidele ridadele Kohanda kodulehte Kodulehe/avalehe read - Laadi serverist - Salvesta serverisse + Laadi kasutajaprofiil serverist + Salvesta kasutajaprofiil serverisse Laadi veebikliendist Kasuta sarja pilti Lisa rida: %1$s @@ -462,4 +462,5 @@ Kuvamise eelseadistused Rakenduses kaasasolevad eelseadistused kõikide ridade kiireks muutmiseks Vali kodulehe/avalehe read ja pildid + Märgi %s lemmikuks From 5d4219f9337747592f92e3a6b9f5cc14bed5f288 Mon Sep 17 00:00:00 2001 From: Outbreak2096 Date: Fri, 20 Feb 2026 07:43:20 +0000 Subject: [PATCH 137/176] Translated using Weblate (Chinese (Simplified Han script)) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hans/ --- app/src/main/res/values-zh-rCN/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 49bab214..28875ea9 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -429,8 +429,8 @@ 应用到所有行 自定义首页 首页行 - 从服务器加载 - 保存到服务器 + 从服务器用户配置加载 + 保存到服务器用户配置 从网络客户端加载 使用剧集图片 为 %1$s 添加行 @@ -445,4 +445,5 @@ 显示预设 可快速设置所有行样式的内置预设 选择首页上的行和图片 + 收藏的 %s From 6ffc2903f340734d39c8cee577ddfd41eb3bfc04 Mon Sep 17 00:00:00 2001 From: idezentas Date: Fri, 20 Feb 2026 12:00:52 +0000 Subject: [PATCH 138/176] Translated using Weblate (Turkish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 58 +++++++++++++------------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 117d6e2f..c65d4b5e 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -65,7 +65,7 @@ Benzerleri Diğer - Filmler + Film Filmler Ad @@ -80,7 +80,7 @@ Kapanış jeneriği Yol - Kişiler + Kişi Kişiler Oynatma Sayısı @@ -151,7 +151,7 @@ İzlenmemiş En İyiler Fragman - Fragmanlar + Fragman Fragmanlar Kayıt Takvimi @@ -159,7 +159,7 @@ Sezon Sezonlar - Diziler + Dizi Diziler Arayüz @@ -187,48 +187,48 @@ Süre Ekstralar - Diğer Ekstralar - + Diğer Ekstra + Diğer Ekstralar Sahne Arkası - + Sahne Arkaları - Tema şarkıları - + Tema şarkısı + Tema şarkıları - Tema Videoları - + Tema Videosu + Tema Videoları - Klipler - + Klip + Klipler - Silinmiş Sahneler - + Silinmiş Sahne + Silinmiş Sahneler - Röportajlar - + Röportaj + Röportajlar - Sahneler - + Sahne + Sahneler - Örnekler - + Örnek + Örnekler - Tanıtımlar - + Tanıtım + Tanıtımlar - Kısa Videolar - + Kısa Video + Kısa Videolar %s indirme @@ -240,7 +240,7 @@ %d öğe - %d öğe + %d öğeler %d saniye @@ -455,8 +455,8 @@ Tüm satırlara uygula Ana ekranı özelleştir Ana ekran satırları - Sunucudan yükle - Sunucuya kaydet + Kullanıcı profilinden yükle + Kullanıcı profiline kaydet Web istemcisinden yükle Dizi görselini kullan %1$s için satır ekle @@ -470,4 +470,6 @@ Ayarlar kaydedildi Görüntü ön ayarları Tüm satırları hızlıca stilize etmek için hazır ayarlar + Favori %s + Ana sayfadaki satırları ve görselleri seçin From 6ef4356ad1b541aca13feecb2259b05de511258f Mon Sep 17 00:00:00 2001 From: alexkahler Date: Fri, 20 Feb 2026 12:29:42 +0000 Subject: [PATCH 139/176] Translated using Weblate (Danish) Currently translated at 97.1% (404 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/da/ --- app/src/main/res/values-da/strings.xml | 197 +++++++++++++++++++++++-- 1 file changed, 182 insertions(+), 15 deletions(-) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 43da96b0..229bc4c8 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -76,7 +76,7 @@ Ingen opdatering tilgængelig Ingen Kun tvungne undertekster - Outro + Eftertekster Filsti Personer @@ -151,7 +151,7 @@ Trailer Trailere - + DVR-plan TV-guide @@ -159,7 +159,7 @@ Sæsoner TV-serier - + Grænseflade Ukendt @@ -187,47 +187,47 @@ Ekstra Andet - + Bag kulisserne - + Temasange - + Temavideoer - + Klip - + Slettede scener - + Interview - + Scener - + Prøver - + Specialindslag - + Kortfilm - + %s download @@ -287,7 +287,7 @@ Adfærd ved at springe reklamer over Spring fremad Adfærd ved at springe intro over - Adfærd ved at springe outro over + Adfærd ved at springe eftertekster over Adfærd ved at springe forhåndsvisninger over Adfærd ved at springe opsummeringer over Opdatering tilgængelig @@ -304,4 +304,171 @@ Undertekststil Baggrundsfarve Nulstil + Afspilning Backend + MPV: Brug hardwareafkodning + Deaktiver, hvis du oplever fejl + MPV indstillinger + ExoPlayer indstillinger + Spring over segmentet + Spring over reklamer + Spring over forhåndsvisning + Spring over opsummering + Spring over eftertekster + Spring over intro + Afspillet + Filter + År + Årti + Fjern + Dolby Vision + Dolby Atmos + MPV: Brug gpu-next + Rediger mpv.conf + Kassér ændringer? + Margin + Detaljeret logning + Indtast PIN + Log ind automatisk + Log ind via server + Tryk på midten for at bekræfte + Kræv PIN-kode til profil + Bekræft PIN + Fejl + PIN-koden skal være 4 cifre eller længere + Vil fjerne PIN + Størrelse på billeddiskcache (MB) + Vis muligheder + Kolonner + Mellemrum + Aspektforhold + Vis detaljer + Billedtype + + Gæsteroller + Kantstørrelse + Skift af billedopdateringsfrekvens + Automatisk + Brug brugernavn/adgangskode + Log ind + Generelt + Container + Titel + Codec + Profil + Niveau + Opløsning + Anamorfisk + Interlaced + Billedfrekvens + Bitdybde + Videoområde + Type af videoområde + Farverum + Farveoverførsel + Primære farver + Pixel-format + Referencerammer + NAL + Sprog + Layout + Kanaler + Samplingsfrekvens + AVC + Ja + Nej + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Vis titler + Gentag + Vis favoritkanaler først + Sorter kanaler efter nyligt set + Farvekod programmer + Undertekstforsinkelse + Stil for baggrundsbillede + Skift af opløsning + Lokal + Afspil trailer + Ingen trailere + Ryd valg af spor + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Direkte afspilning af Dolby Vision Profile 7 + Ignorerer kontrol af enhedskompatibilitet + Opdag + Anmod + Afventer + Seerr integration + Fjern Seerr server + Seerr server tilføjet + Quick Connect + Godkend en anden enhed til at logge ind på din konto + Indtast Quick Connect-kode + Koden skal være på 6 cifre + Enheden er godkendt + Adgangskode + Brugernavn + URL + Populært lige nu + Kommende film + Kommende tv-serier + Anmod i 4K + AV1 software afkodning + MPV er nu standardafspilleren undtagen for HDR.\nDu kan ændre dette i indstillingerne. + HDR-undertekststil + Billedunderteksters gennemsigtighed + Lysstyrke + Kontrast + Mætning + Farvetone + Rød + Grøn + Blå + Sløring + Gem i album + Afspil diasshow + Stop diasshow + Fra begyndelsen + Ikke flere billeder + Roter til venstre + Roter til højre + Zoom ind + Zoom ud + Diasshowvarighed + Afspil videoer under diasshow + Send medieinformationslog til serveren + Ingen grænse + Maks. antal dage i Næste + Tilføj række + Genrer i %1$s + Nyligt udkommet i %1$s + Højde + Anvend på alle rækker + Tilpas startsiden + Rækker på startsiden + Indlæs fra brugerprofilen på serveren + Gem i brugerprofilen på serveren + Indlæs fra webklienten + Brug seriens billede + Tilføj række for %1$s + Overskriv indstillinger på serveren? + Overskriv lokale indstillinger? + For episoder + Forslag til %1$s + Øg størrelsen på alle kort + Reducer størrelsen på alle kort + Brug thumb-billeder + Indstillinger gemt + Skærmforudindstillinger + Indbyggede forudindstillinger til hurtigt at style alle rækker + Vælg rækker og billeder på startsiden + Reklame From 4ad96ea29e565e95b9d3fc15a74fa58f576dec63 Mon Sep 17 00:00:00 2001 From: arcker95 Date: Fri, 20 Feb 2026 20:18:49 +0000 Subject: [PATCH 140/176] Translated using Weblate (Italian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/it/ --- app/src/main/res/values-it/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 0aadb5bd..a3764b96 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -461,7 +461,7 @@ Recentemente rilasciato in %1$s Altezza Applica a tutte le righe - Carica dal server + Carica dal profilo utente del server Aggiungi riga per %1$s Sovrascrivere le impostazioni sul server? Sovrascrivere le impostazioni locali? @@ -475,8 +475,9 @@ Righe home Per gli episodi Personalizza home page - Salva sul server + Salva nel profilo utente del server Carica dal client web Usa immagine delle serie Scegli righe e immagini nella home page + Preferito %s From 40a3d70ba87c603b1e7b6a1a70ec70dfbc666634 Mon Sep 17 00:00:00 2001 From: danyrd92 Date: Fri, 20 Feb 2026 22:55:40 +0000 Subject: [PATCH 141/176] Translated using Weblate (Spanish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 831c6036..ad5cd773 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -12,7 +12,7 @@ Elegir %1$s Colección Colecciones - Comercial + Anuncio Confirmar Continuar viendo %.2f segundos @@ -119,7 +119,7 @@ Servidores detectados Descargando… - Introduce la IP o URL del servidor, incluido el puerto + Introduce la IP o URL del servidor Error cargando la colección %1$s Forzado Ir a la serie @@ -462,8 +462,8 @@ Aplicar a todas las filas Personalizar página de inicio Filas de inicio - Cargar desde el servidor - Guardar en el servidor + Cargar perfil de usuario desde el servidor + Guardar perfil de usuario en el servidor Cargar del cliente web Usar imágenes de la serie Agregar fila para %1$s @@ -479,4 +479,5 @@ Preajustes integrados para estilizar todas las filas Elegir filas e imágenes en la página de inicio Lanzado recientemente en %1$s + Favoritos%s From e536c0a9e79f0efbb9fe220dafa619d975e64865 Mon Sep 17 00:00:00 2001 From: idezentas Date: Sat, 21 Feb 2026 10:40:37 +0000 Subject: [PATCH 142/176] Translated using Weblate (Turkish) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/tr/ --- app/src/main/res/values-tr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c65d4b5e..aec35e54 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -52,7 +52,7 @@ Ana Ekran Hemen İntro - #ABCÇDEFGĞHIİJKLMNOÖPRSŞTUÜVYZ + #ABCDEFGHIJKLMNOPQRSTUVWXYZ Kütüphane Lisans bilgileri Canlı TV From 191966124b2854bf5bc23f5249ff4d294df4c86f Mon Sep 17 00:00:00 2001 From: n8llcaster Date: Sun, 22 Feb 2026 14:10:44 +0000 Subject: [PATCH 143/176] Translated using Weblate (Slovak) Currently translated at 53.6% (223 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/sk/ --- app/src/main/res/values-sk/strings.xml | 209 ++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index c55a7acc..195565df 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -22,7 +22,7 @@ Potvrdiť Pokračovať v pozeraní Hodnotenie kritikov - %.1f sekúnd + %.2f sekúnd Predvolené Vymazať Zomrel @@ -102,4 +102,211 @@ Vybrať používateľa Zobraziť informácie o debugu Došlo k chybe! Stlačte tlačidlo, aby ste odoslali logy na svoj server. + Končí o %1$s + Zadajte adresu servera + Nenašli sa žiadne servery + Iba vynútené titulky + Hlasové vyhľadávanie + Spúšťanie + Hovorením vyhľadávajte + Spracovanie + Stlačte späť pre zrušenie + Chyba nahrávania zvuku + Chyba rozpoznávania hlasu + Vyžaduje sa povolenie na prístup k mikrofónu + Chyba siete + Časový limit siete + Nebol rozpoznaný žiadny hlas + Rozpoznávanie hlasu je zaneprázdnené + Chyba servera + Nebola zistená žiadna reč + Neznáma chyba + Nepodarilo sa spustiť rozpoznávanie hlasu + Časový limit rozpoznávania hlasu vypršal + Skúsiť znova + Zobraziť ďalšie + Zobraziť + Náhodne + Preskočiť + Dátum pridania + Dátum pridania epizódy + Dátum prehratia + Dátum vydania + Názov + Náhodne + Štúdiá + Odoslať + Sťahovanie trvá dlho, možno budete musieť reštartovať prehrávanie + Podnázov + Titulky + Návrhy + Zmeniť servery + Zmeniť + Najlepšie hodnotené Nepozreté + Trailer + + Trailery + + + + + DVR rozvrh + Sprievodca + Séria + Série + + Seriály + + + + + Rozhranie + Neznámy + Aktualizácie + Verzia + Mierka videa + Video + Videá + Sledovať naživo + %1$d rokov + Prehrávať s prekódovaním + Informácie o médiu + Zobraziť hodiny + Poradie odvysielaných epizód + Vytvoriť nový playlist + Pridať do playlistu + Pozastavenie jedným kliknutím + Stlačte stred D-Padu pre pozastavenie/prehrávanie + Kurzívový font + Font + Pozadie + Úspech + Rodičovské hodnotenie + Trvanie + Bonusový materiál + + Ostatné + + + + + + Zo zákulisia + + + + + + Tématické piesne + + + + + + Tématické videá + + + + + + Klipy + + + + + + Vymazané scény + + + + + + Rozhovory + + + + + + Scény + + + + + + Vzorky + + + + + + Krátkometrážne filmy + + + + + + Krátke videá + + + + + Obľúbené %s + + %s stiahnutý + %s stiahnuté + %s stiahnutých + %s stiahnutých + + + %d hodina + %d hodiny + %d hodín + %d hodín + + + %s deň + %s dni + %s dní + %s dní + + + %d položka + %d položky + %d položiek + %d položiek + + + %d sekunda + %d sekundy + %d sekúnd + %d sekúnd + + Zariadenie podporuje AC3/Dolby Digital + Pokročilé nastavenia + Pokročilé UI + Téma aplikácie + Automaticky kontrolovať aktualizácie + Skontrolovať aktualizácie + Platí len pre seriály + Priame prehrávanie ASS titulkov + Priame prehrávanie PGS titulkov + Vždy downmixovať do stereo formátu + Použiť modul FFmpeg dekodéra + Predvolená mierka obsahu + Nainštalovať aktualizáciu + Nainštalovaná verzia + Maximálny počet položiek v riadkoch na domovskej obrazovke + Vyberte predvolené položky, ktoré sa majú zobraziť, ostatné budú skryté + Prispôsobenie položiek navigačného panela + Kliknutím prepnete stránky + Prepnúť stránky navigačného panelu pri zaostrení + Ochrana proti výpadku + Prehrať tématickú hudbu + Zapamätať vybrané karty + Kroky seek baru + Užitočné pre debugovanie + Odoslať logy aplikácie na aktuálny server + Pokus o odoslanie na posledný pripojený server + Odoslať správy o zlyhaní + Nastavenia From 0a4f21cc11990e42c50a672bc7d47fa2d824181a Mon Sep 17 00:00:00 2001 From: Fjuro Date: Sun, 22 Feb 2026 13:36:04 +0000 Subject: [PATCH 144/176] Translated using Weblate (Czech) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index f0e3d0be..42085349 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -495,8 +495,8 @@ Použít na všechny řádky Přizpůsobit domovskou stránku Řádky na domovské stránce - Načíst ze serveru - Uložit na server + Načíst ze serverového uživatelského profilu + Uložit do serverového uživatelského profilu Načíst z webového klienta Použít obrázek seriálu Přidat řádek pro %1$s @@ -511,4 +511,5 @@ Zobrazit předvolby Vestavěné předvolby pro rychlou úpravu všech řádků Vyberte řádky a obrázky na domovské stránce + Oblíbené %s From bfa0cd0b05fbd631d1d785e6682b8f1e1f247f7f Mon Sep 17 00:00:00 2001 From: Sathen Date: Sun, 22 Feb 2026 19:41:20 +0000 Subject: [PATCH 145/176] Translated using Weblate (Ukrainian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/uk/ --- app/src/main/res/values-uk/strings.xml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index ea7e0323..1a31690d 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -474,4 +474,42 @@ Зменшити Тривалість слайд-шоу Відтворення відео під час слайд-шоу + Улюблені %s + + день + дні + днів + днів + + Швидке підключення + Дозвольте іншому пристрою увійти у ваш обліковий запис + Введіть код швидкого підключення + Код повинен складатися з 6 цифр + Пристрій успішно авторизовано + Надіслати інформацію про медіа на сервер + Без обмежень + Максимальна кількість днів в Очікуються + Додати рядок + Жанри %1$s + Нещодавно додані %1$s + Висота + Застосувати для всіх рядків + Змінити головну сторінку + Рядки на головній сторінці + Завантажувати профіль користувача з сереверу + Зберегти профіль користувача на сервер + Завантажити з веб клієнта + Використовувати зображення серіалу + Додати рядок для %1$s + Перезаписати налаштування на сервері? + Перезаписати налаштування на цьому пристрої? + Для епізодів + Рекомендації %1$s + Збільшити розмір карток + Зменшити розмір карток + Використовувати мініатюру + Налаштування збережені + Налаштування дисплея + Вбудовані налаштування для швидкого оформлення всіх рядків + Обери рядки і зображення для головної сторінки From ca425422befe35ec111a79dc5d8edaa1387384ff Mon Sep 17 00:00:00 2001 From: opakholis Date: Mon, 23 Feb 2026 02:29:50 +0000 Subject: [PATCH 146/176] Translated using Weblate (Indonesian) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/id/ --- app/src/main/res/values-in/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 91708cf7..356732cd 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -429,8 +429,8 @@ Terapkan ke semua baris Kustomisasi beranda Baris beranda - Muat dari server - Simpan ke server + Muat dari profil pengguna server + Simpan ke profil pengguna server Muat dari klien web Gunakan gambar seri Tambah baris untuk %1$s @@ -445,4 +445,5 @@ Preset tampilan Preset bawaan untuk kustomisasi cepat semua baris Pilih baris dan gambar di halaman beranda + Favoritkan %s From f1c2ff453076f5a39f82cb77a842077364cea2cd Mon Sep 17 00:00:00 2001 From: ymir Date: Mon, 23 Feb 2026 04:42:52 +0000 Subject: [PATCH 147/176] Translated using Weblate (French) Currently translated at 100.0% (416 of 416 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/fr/ --- app/src/main/res/values-fr/strings.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ae405a53..538fd523 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -463,8 +463,8 @@ S\'applique à toutes les lignes Personnaliser la page d\'accueil Lignes d\'accueil - Charger depuis le serveur - Enregistrer sur le serveur + Charger à partir du profil utilisateur du serveur + Enregistrer dans le profil utilisateur du serveur Charger à partir du client Web Utiliser l\'image de la série Ajouter une ligne pour %1$s @@ -479,4 +479,5 @@ Afficher les préréglages Préréglages intégrés pour styliser rapidement toutes les lignes Choisissez des lignes et des images sur la page d\'accueil + Favori %s From 34174cea55ba0f1e304f55421b8b94e2dfc3a7d4 Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 23 Feb 2026 17:28:03 +0000 Subject: [PATCH 148/176] Translated using Weblate (Spanish) Currently translated at 91.9% (434 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index ad5cd773..a74387bf 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -480,4 +480,20 @@ Elegir filas e imágenes en la página de inicio Lanzado recientemente en %1$s Favoritos%s + Ajustar + Recortar + Rellenar + Ajustar al ancho + Ajustar al alto + Miniaturas de las series + Miniaturas de los episodios + Mínimo + Bajo + Medio + Alto + Máximo + Morado + Naranja + Azul intenso + Negro From 2a90d12cbc03125d63777951e5fd47c10aa0654e Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 23 Feb 2026 17:34:00 +0000 Subject: [PATCH 149/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 91.3% (431 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 1cae12e0..85ad4ed1 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -436,8 +436,8 @@ 選擇首頁要顯示的列項目及圖片樣式 高度 自訂首頁 - 從伺服器載入 - 保存到伺服器 + 從伺服器載入設定 + 保存設定到伺服器 從網頁版載入 使用劇集圖片 覆蓋伺服器上的設定? @@ -445,4 +445,18 @@ 設定已儲存 顯示預設樣式 針對單集 + 原比例縮放 + 裁切填滿 + 拉伸填滿 + 填滿寬度 + 填滿高度 + 最低 + + 中等 + + 最大 + 紫色 + 橘色 + 深藍色 + 黑色 From 0ce2ff907c2ba457d6e422f463c5f08895fc687d Mon Sep 17 00:00:00 2001 From: danpergal84 Date: Mon, 23 Feb 2026 18:33:14 +0000 Subject: [PATCH 150/176] Translated using Weblate (Spanish) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/es/ --- app/src/main/res/values-es/strings.xml | 38 ++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a74387bf..b4ef66da 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -496,4 +496,42 @@ Naranja Azul intenso Negro + Póster + Póster compacto + Ignorar + Omitir automáticamente + Preguntar para omitir + Usar FFmpeg solo si no existe un decodificador integrado + Preferir FFmpeg sobre los decodificadores integrados + No usar nunca decodificadores FFmpeg + Al finalizar la reproducción + Durante los créditos finales + Blanco + Gris claro + Gris oscuro + Amarillo + Cian + Magenta + Contorno + Sombra + Ajustado al texto + Enmarcado + Preferir MPV + Usar ExoPlayer para reproducir contenido HDR + Póster (2:3) + 16:9 + 4:3 + Cuadrada (1:1) + Primaria + Miniatura + Imagen con color dinámico + Imagen + + Clave API + Iniciar sesión en el servidor Seerr + Usuario de Jellyfin + Usuario local + + No se encontraron subtítulos externos + Actualidad From a55399cb683c900d9e313d8fb011a383fedaac4e Mon Sep 17 00:00:00 2001 From: SimonHung Date: Mon, 23 Feb 2026 17:49:15 +0000 Subject: [PATCH 151/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 92.5% (437 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 85ad4ed1..a145fe45 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -459,4 +459,10 @@ 橘色 深藍色 黑色 + 不處理 + 自動跳過 + 詢問是否跳過 + 僅在無內建解碼器時使用 FFmpeg + 優先使用 FFmpeg 而非內建解碼器 + 不使用 FFmpeg 解碼器 From 3b736a136b048a1b0bf70fe0ee7ddf7e86a1a18f Mon Sep 17 00:00:00 2001 From: RafaelSoaresP Date: Mon, 23 Feb 2026 22:55:08 +0000 Subject: [PATCH 152/176] Translated using Weblate (Portuguese (Brazil)) Currently translated at 81.9% (387 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/pt_BR/ --- app/src/main/res/values-pt-rBR/strings.xml | 429 +++++++++++++++++++++ 1 file changed, 429 insertions(+) diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index c7889af3..76bf308f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -31,4 +31,433 @@ Diretor Desabilitado Servidores encontrados + Baixando… + Habilitado + Termina às %1$s + Insira o IP ou URL do Servidor + Insira o endereço do servidor + Episódios + Erro ao carregar a coleção %1$s + Externo + Favoritos + Tamanho + Forçado + Gêneros + Ir para séries + Ir Para + Ocultar controles de reprodução + Ocultar informações de depuração + Ocultar + Início + Imediato + Introdução + #ABCDEFGHIJKLMNOPQRSTUVWXYZ + Biblioteca + Informação de licenças + TV ao Vivo + Carregando… + Marcar série inteira como reproduzida? + Marcar séria inteira como não reproduzida? + Marcar como não assistida + Marcar como assistida + Taxa de bits máxima + Mais como isso + Mais + + Filmes + + + + Nome + A Seguir + Sem dados + Sem resultados + Nenhum servidor encontrado + Nenhuma gravação agendada + Nenhuma atualização disponível + Nenhum(a) + Apenas Legendas Forçadas + Finalização + Caminho + + Pessoas + + + + Contagem de Reproduções + Reproduzir a partir daqui + Reproduzir + Mostrar informações de depuração da reprodução + Velocidade de Reprodução + Reprodução + Lista de reprodução + Listas de reprodução + Prévia + Configurações do Perfil de Usuário + Fila + Recapitulação + Recentemente adicionado em %1$s + Recentemente adicionado + Recentemente Gravado + Recentemente Publicado + Recomendado + Gravar Programa + Gravar Série + Desfavoritar + Reiniciar + Continuar + Salvar + Procurar + Procurando… + Pesquisa por voz + Iniciando + Fale para pesquisar + Processando + Aperte voltar para cancelar + Erro de gravação de áudio + Erro de reconhecimento de voz + Permissão de microfone necessária + Erro de rede + Tempo limite de rede esgotado + Nenhuma fala reconhecida + Reconhecimento de voz ocupado + Erro de servidor + Nenhuma fala detectada + Erro desconhecido + Falha no início do reconhecimento + Tempo limite do reconhecimento de voz esgotado + Tentar novamente + Selecionar Servidor + Selecionar Usuário + Mostrar informações de depuração + Mostrar próximo + Mostrar + Aleatório + Pular + Data de Adição + Data de Adição do Episódio + Data de Reprodução + Data de Lançamento + Nome + Aleatório + Estúdios + Enviar + O download está demorando muito, talvez você tenha que reiniciar a reprodução + Legenda + Legendas + Sugestões + Trocar de servidores + Trocar + Não assistidos com melhor avaliação + Trailer + + Trailers + + + + Agenda do DVR + Guia + Temporada + Temporadas + + Programas de TV + + + + Interface + Desconhecido + Atualizações + Versão + Escala de Vídeo + Vídeo + Vídeos + Assista ao vivo + %1$d anos de idade + Reproduzir com transcodificação + Informação de Mídia + Mostrar Relógio + Ordem de Lançamento do Episódio + Criar nova lista de reprodução + Adicionar a lista de reprodução + Pausar com um clique + Aperte o centro do controle direcional para pausar/reproduzir + Colocar a fonte em itálico + Fonte + Plano de fundo + Sucesso + Classificação Parental + Duração + Extras + + Outro + + + + + Por Trás das Cenas + + + + + Músicas Tema + + + + + Vídeos Tema + + + + + Clipes + + + + + Cenas Deletadas + + + + + Entrevistas + + + + + Cenas + + + + + Amostras + + + + + Curtas + + + + Favoritar %s + + %s download + %s downloads + %s downloads + + + %d hora + %d horas + %d horas + + + %s dia + %s dias + %s dias + + + %d item + %d itens + %d itens + + + %d segundo + %d segundos + %d segundos + + Dispositivo suporta AC3/Dolby Digital + Configurações Avançadas + IU Avançada + Tema do Aplicativo + Checar atualizações automaticamente + Atraso antes da próxima reprodução + Reproduzir próximo automaticamente + Checar atualizações + Aplicar apenas a Séries de TV + Reproduzir legendas ASS diretamente + Reproduzir legendas PGS diretamente + Sempre mixar para estéreo + Usar módulo de decodificação do FFmpeg + Escala padrão do conteúdo + Instalar atualização + Versão instalada + Máximo de itens nas linhas da página inicial + Escolher itens exibidos por padrão, os outros serão ocultados + Personalizar os Itens da Gaveta de Navegação + Clicar para trocar de páginas + Trocar a página de navegação no foco + Proteção Contra Desmaios + Tocar música tema + Sobreposições de reprodução + Lembrar abas selecionadas + Habilitar reassistir em a seguir + Passos da barra de busca + Útil para depuração + Enviar os registros do aplicativo para o servidor atual + Tentativa de envio para o último servidor conectado + Enviar Relatórios de Falhas + Configurações + Comportamento de pular os comerciais + Comportamento de pular as introduções + Comportamento de pular a finalização + Comportamento de pular as prévias + Comportamento de pular as recapitulações + Atualização disponível + URL usada para verificação de atualizações + URL de atualização + Tamanho da fonte + Cor da fonte + Opacidade da fonte + Estilo da borda + Cor da borda + Opacidade do fundo + Estilo do fundo + Estilo da legenda + Cor do fundo + Redefinir + Fonte em negrito + Backend de reprodução + MPV: Usar decodificação baseada em hardware + Desabilite se encontrar falhas + Opções do MPV + Opções do ExPlayer + Pular Segmento + Pular Anúncios + Pular Prévia + Pular Recapitulação + Pular Finalização + Pular Introdução + Reproduzido + Filtro + Ano + Década + Remover + Dolby Vision + Dolby Atmos + MPV: Usar gpu-next + Editar mpv.conf + Descartar mudanças? + Margem + Registro extenso + Insira o PIN + Entrar automaticamente + Autenticar via servidor + Aperte o botão central para confirmar + Exigir PIN para o perfil + Confirmar o PIN + Incorreto + PIN deve ter 4 ou mais dígitos + Irá remover o PIN + Tamanho do cache de imagens no disco (MB) + Ver opções + Colunas + Espaçamento + Proporção da Tela + Mostrar detalhes + Tipo de imagem + Estrelas convidadas + Tamanho da borda + Troca de taxa de atualização + Automático + Usar nome de usuário / senha + Autenticação + Geral + Contêiner + Título + Codec + Perfil + Nível + Resolução + Anamórfico + Entrelaçado + Taxa de atualização + Profundidade de bits + Alcance de vídeo + Tipo de alcance de vídeo + Espaço de cores + Transferência de cores + Primárias de cores + Formato de pixel + Quadros de referência + Idioma + Layout + Canais + Taxa de amostra + Sim + Não + SDR + HDR + HDR10 + HDR10+ + HLG + bit + Hz + Mostrar títulos + Repetir + Mostrar canais favoritos primeiro + Ordenar canais por recentemente assistidos + Codificar programas por cor + Atraso na legenda + Estilo de fundo + Troca de resolução + Local + Reproduzir trailer + Nenhum trailer + Limpar escolhas de faixas + DTS:X + DTS:HD + TrueHD + DD + DD+ + DTS + Reprodução direta de Dolby Vision Profile 7 + Ignorar checagens de compatibilidade de dispositivo + Descobrir + Solicitar + Pendente + Integração com o Seerr + Remover Servidor Seerr + Servidor Seerr adicionado + Conexão Rápida + Autorizar outro dispositivo a se autenticar com sua conta + Insira o código de conexão rápida + O código deve ter 6 dígitos + Dispositivo autorizado com sucesso + Senha + Nome de usuário + URL + Tendências + Filmes Lançados em Breve + Programas de TV Lançados em Breve + Solicitar em 4K + Decodificação de AV1 via software + MPV é o reprodutor padrão, exceto para HDR\nVocê pode alterar isso nas configurações. + Estilo da legenda HDR + Opacidade da legenda de imagem + Brilho + Contraste + Saturação + Matiz + Vermelho + Verde + Azul + Desfoque + Reproduzir apresentação de slides + Interromper apresentação de slides + No começo + Sem mais fotos + Girar a esquerda + Girar a direita + Mais zoom + Menos zoom + Duração da reprodução de slides + Reproduzir vídeos ao longo da apresentação de slides + Enviar registro de informações da mídia ao servidor + Sem limite + Máximo de dias no A Seguir + Adicionar linha + Gêneros em %1$s + Recentemente lançado em %1$s + Altura + Aplicar a todas as linhas + Personalizar página inicial From 3bdd177230278354f76618d81c63204e2ae4a956 Mon Sep 17 00:00:00 2001 From: Fjuro Date: Tue, 24 Feb 2026 09:59:56 +0000 Subject: [PATCH 153/176] Translated using Weblate (Czech) Currently translated at 100.0% (472 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/cs/ --- app/src/main/res/values-cs/strings.xml | 54 ++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 42085349..8f32bc3a 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -512,4 +512,58 @@ Vestavěné předvolby pro rychlou úpravu všech řádků Vyberte řádky a obrázky na domovské stránce Oblíbené %s + Přizpůsobit + Oříznout + Vyplnit + Vyplnit šířku + Vyplnit výšku + Výchozí předvolba Wholphin + Wholphin kompaktní + Obrázky náhledů seriálů + Obrázky náhledů epizod + Nejnižší + Nízká + Střední + Vysoká + Plná + Fialová + Oranžová + Odvážná modrá + Černá + Ignorovat + Automaticky přeskočit + Zeptat se + Použít FFmpeg pouze v případě, že neexistuje vestavěný dekodér + Preferovat FFmpeg nad vestavěnými dekodéry + Nikdy nepoužívat dekodéry FFmpeg + Na konci přehrávání + Během titulků + Bílá + Světle šedá + Tmavě šedá + Žlutá + Azurová + Magenta + Okrajová linka + Stín + Zabalené + Krabicové + Preferovat MPV + Použít ExoPlayer pro přehrávání HDR obsahu + Plakát (2:3) + 16:9 + 4:3 + Čtverec (1:1) + Primární + Náhled + Obrázek s dynamickou barvou + Pouze obrázek + + Klíč API + Přihlásit se k serveru Seerr + Uživatel Jellyfin + Lokální uživatel + + Nenalezeny žádné vzdálené titulky + Současnost From 043f8cd3cfa9094ddd46254f6a23816f14b1745d Mon Sep 17 00:00:00 2001 From: SimonHung Date: Tue, 24 Feb 2026 17:25:41 +0000 Subject: [PATCH 154/176] Translated using Weblate (Chinese (Traditional Han script)) Currently translated at 95.3% (450 of 472 strings) Translation: Wholphin/Wholphin Translate-URL: https://translate.codeberg.org/projects/wholphin/wholphin/zh_Hant/ --- app/src/main/res/values-zh-rTW/strings.xml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a145fe45..6670d10e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -465,4 +465,17 @@ 僅在無內建解碼器時使用 FFmpeg 優先使用 FFmpeg 而非內建解碼器 不使用 FFmpeg 解碼器 + 在播放結束時 + 在播到片尾時 + 白色 + 淺灰色 + 深灰色 + 黃色 + 青色 + 洋紅色 + 外框 + 陰影 + 貼合文字 + 長方底框 + 優先使用 MPV From 33cd4d04aa2a331fce05b3ff39157556d6d72477 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:01:52 -0500 Subject: [PATCH 155/176] Release v0.5.1 From 69be7311ab372b6a8bc2a0e0e747408fcbb0711a Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:15:04 -0500 Subject: [PATCH 156/176] Fix verify step --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86c17c4e..c654dad9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,9 @@ jobs: echo "aab=$aab" >> "$GITHUB_OUTPUT" - name: Verify signatures run: | - echo "Verify APK/AAB signatures" + echo "Verify APK signatures" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) - find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs + find app/build/outputs \( -name '*.apk' \) -print0 | xargs -0 -n1 ${{env.ANDROID_SDK_ROOT}}/build-tools/${{ env.BUILD_TOOLS_VERSION }}/apksigner verify --verbose --print-certs - name: Copy APK to shorter names id: apks run: | From 1b94ab9bec9613999c3d2216ea2b16a50afc51ef Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 24 Feb 2026 16:15:25 -0500 Subject: [PATCH 157/176] Release v0.5.1 From 536e8d366c1a720cec129fe0edc0543ca9c601be Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:31:52 -0500 Subject: [PATCH 158/176] Update Dependencies to v3.4.0 (#970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [io.coil-kt.coil3:coil-svg](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-svg/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-svg/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-gif](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-gif/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-gif/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-network-okhttp](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-network-okhttp/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-network-okhttp/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-network-cache-control](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-network-cache-control/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-network-cache-control/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-compose](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-compose/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-compose/3.3.0/3.4.0?slim=true) | | [io.coil-kt.coil3:coil-core](https://redirect.github.com/coil-kt/coil) | `3.3.0` → `3.4.0` | ![age](https://developer.mend.io/api/mc/badges/age/maven/io.coil-kt.coil3:coil-core/3.4.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/maven/io.coil-kt.coil3:coil-core/3.3.0/3.4.0?slim=true) | --- ### Release Notes
coil-kt/coil (io.coil-kt.coil3:coil-svg) ### [`v3.4.0`](https://redirect.github.com/coil-kt/coil/blob/HEAD/CHANGELOG.md#340---Unreleased) [Compare Source](https://redirect.github.com/coil-kt/coil/compare/3.3.0...3.4.0) - **New**: Add `ConcurrentRequestStrategy` to support combining in-flight network requests for the same key. ([#​2995](https://redirect.github.com/coil-kt/coil/pull/2995), [#​3326](https://redirect.github.com/coil-kt/coil/pull/3326)) - `DeDupeConcurrentRequestStrategy` enables this behavior and lets waiters wait for the results of an in-flight network request. - This behavior is experimental and is currently disabled by default. - Currently, requests are always combined based on their `diskCacheKey`. - `OkHttpNetworkFetcherFactory`, `KtorNetworkFetcherFactory`, and `NetworkFetcher.Factory` now accept `concurrentRequestStrategy`. - **New**: Decode images on JS/WASM using a web worker to avoid blocking the browser main thread. ([#​3305](https://redirect.github.com/coil-kt/coil/pull/3305)) - **New**: Add support for Linux native targets (`linuxX64` and `linuxArm64`) for non-Compose multiplatform artifacts. ([#​3054](https://redirect.github.com/coil-kt/coil/pull/3054)) - **New**: Add Compose-only APIs to improve transitions between subsequent requests. ([#​3141](https://redirect.github.com/coil-kt/coil/pull/3141), [#​3175](https://redirect.github.com/coil-kt/coil/pull/3175)) - `ImageRequest.Builder.useExistingImageAsPlaceholder` enables crossfading from the previous image when no placeholder is set. - `ImageRequest.Builder.preferEndFirstIntrinsicSize` lets `CrossfadePainter` prefer the end painter's intrinsic size. - **New**: Add `ImageLoader.Builder.repeatCount(Int)` in `coil-gif` to set a global animated image repeat count. ([#​3143](https://redirect.github.com/coil-kt/coil/pull/3143)) - **New**: Add support for preferring embedded video thumbnails in `coil-video`. ([#​3107](https://redirect.github.com/coil-kt/coil/pull/3107)) - **New**: Publish `coil-lint` with `coil-core` and add a lint check to catch accidental `kotlin.error()` calls in `ImageRequest.Builder` blocks. ([#​3304](https://redirect.github.com/coil-kt/coil/pull/3304)) - Set Kotlin language version to 2.1. ([#​3302](https://redirect.github.com/coil-kt/coil/pull/3302)) - Make `BitmapFetcher` available in common code. ([#​3286](https://redirect.github.com/coil-kt/coil/pull/3286)) - Use `applicationContext` when creating the singleton `ImageLoader` on Android. ([#​3246](https://redirect.github.com/coil-kt/coil/pull/3246)) - Cache eligible non-2xx HTTP responses by default (e.g. `404`) and stop caching non-cacheable responses (e.g. `500`). ([#​3137](https://redirect.github.com/coil-kt/coil/pull/3137), [#​3139](https://redirect.github.com/coil-kt/coil/pull/3139)) - Fix potential race condition when consuming OkHttp response bodies. ([#​3186](https://redirect.github.com/coil-kt/coil/pull/3186)) - Fix `maxBitmapSize` edge case to prevent oversized bitmap crashes on Android. ([#​3259](https://redirect.github.com/coil-kt/coil/pull/3259)) - Update Kotlin to 2.3.10. - Update Compose to 1.9.3. - Update Okio to 3.16.4. - Update Skiko to 0.9.22.2. - Update `kotlinx-io-okio` to 0.9.0. - Update `androidx.core` to 1.16.0. - Update `androidx.lifecycle` to 2.9.4. - Update `androidx.exifinterface` to 1.4.2.
--- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c2234f0..06a1b888 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,7 +25,7 @@ tvMaterial = "1.0.1" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.12.4" androidx-media3 = "1.9.2" -coil = "3.3.0" +coil = "3.4.0" jellyfin-sdk = "1.7.1" nav3Core = "1.0.1" lifecycleViewmodelNav3 = "2.10.0" From a7e86fbc15fd56170b97ec7b61d98faa088ef2d1 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 14:11:00 -0500 Subject: [PATCH 159/176] Add in-app & Android TV screensaver (#946) ## Description Adds both an optional in-app and Android TV OS (DreamService) screensaver Has settings for: - Starting delay, options from 30s to 1 hour - Duration to show each image, 30s to 2 minutes - Show clock (separate from app-wide clock) - Toggle animated zooming - Set a max age rating to limit what content is shown - Included media types: movies, tv shows, and/or photos By default only Movies & TV Shows are included. But if you have like family albums on your server, you could enable Photos too. There's also an button to start the screensaver so you can see what it looks like. ### Related issues Closes #230 ### Testing Emulator, Onn 4k pro, nvidia shield using both in-app and OS screensavers ## Screenshots ![screensaver_settings Large](https://github.com/user-attachments/assets/9b3240dd-81cb-4c20-bc4e-b2bf38698b2f) ## AI or LLM usage None ## Acknowledgements Some of the skeleton code for setting up the `DreamService` with the right lifecycle was copied and modified from the [official app's implementation](https://github.com/jellyfin/jellyfin-androidtv/blob/master/app/src/main/java/org/jellyfin/androidtv/integration/dream/DreamServiceCompat.kt). --- app/src/main/AndroidManifest.xml | 11 + .../damontecres/wholphin/MainActivity.kt | 32 ++- .../wholphin/WholphinDreamService.kt | 98 ++++++++ .../wholphin/preferences/AppPreference.kt | 27 ++- .../preferences/AppPreferencesSerializer.kt | 20 ++ .../preferences/ScreensaverPreference.kt | 183 ++++++++++++++ .../wholphin/services/AppUpgradeHandler.kt | 17 ++ .../wholphin/services/ImageUrlService.kt | 25 +- .../wholphin/services/ScreensaverService.kt | 224 ++++++++++++++++++ .../services/UserPreferencesService.kt | 3 + .../damontecres/wholphin/ui/Extensions.kt | 15 +- .../wholphin/ui/components/AppScreensaver.kt | 214 +++++++++++++++++ .../wholphin/ui/nav/ApplicationContent.kt | 4 +- .../ui/playback/AmbientPlayerListener.kt | 32 --- .../wholphin/ui/playback/PlaybackPage.kt | 1 - .../wholphin/ui/playback/PlaybackViewModel.kt | 11 +- .../ui/preferences/ComposablePreference.kt | 10 +- .../ui/preferences/MultiChoicePreference.kt | 6 +- .../ui/preferences/PreferenceUtils.kt | 1 + .../ui/preferences/PreferencesContent.kt | 46 +++- .../ui/preferences/PreferencesViewModel.kt | 2 + .../wholphin/ui/slideshow/SlideshowPage.kt | 9 - .../ui/slideshow/SlideshowViewModel.kt | 5 + app/src/main/proto/WholphinDataStore.proto | 11 + app/src/main/res/values/strings.xml | 25 ++ 25 files changed, 954 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt delete mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 161352af..4479ae86 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,17 @@ android:name="androidx.startup.InitializationProvider" android:authorities="${applicationId}.androidx-startup" tools:node="remove" /> + + + + + + + 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 b7f4b1d3..7b6f0c9b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -3,10 +3,12 @@ package com.github.damontecres.wholphin import android.content.Intent import android.content.res.Configuration import android.os.Bundle +import android.view.KeyEvent import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -49,6 +51,7 @@ import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager @@ -59,8 +62,10 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService +import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -134,6 +139,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var datePlayedInvalidationService: DatePlayedInvalidationService + @Inject + lateinit var screensaverService: ScreensaverService + private var signInAuto = true @OptIn(ExperimentalTvMaterial3Api::class) @@ -317,6 +325,15 @@ class MainActivity : AppCompatActivity() { } }, ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } } } } @@ -324,10 +341,22 @@ class MainActivity : AppCompatActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (screensaverService.state.value.show) { + screensaverService.stop(false) + screensaverService.pulse() + return true + } else { + screensaverService.pulse() + return super.dispatchKeyEvent(event) + } + } + override fun onResume() { super.onResume() Timber.d("onResume") - lifecycleScope.launchIO { + lifecycleScope.launchDefault { + screensaverService.pulse() appUpgradeHandler.run() } } @@ -341,6 +370,7 @@ class MainActivity : AppCompatActivity() { override fun onStop() { super.onStop() Timber.d("onStop") + screensaverService.stop(true) tvProviderSchedulerService.launchOneTimeRefresh() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt new file mode 100644 index 00000000..1c0e4e0e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -0,0 +1,98 @@ +package com.github.damontecres.wholphin + +import android.service.dreams.DreamService +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistry +import androidx.savedstate.SavedStateRegistryController +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.ui.components.AppScreensaverContent +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds + +@AndroidEntryPoint +class WholphinDreamService : + DreamService(), + SavedStateRegistryOwner { + @Inject + lateinit var screensaverService: ScreensaverService + + @Inject + lateinit var userPreferencesService: UserPreferencesService + + private val lifecycleRegistry = LifecycleRegistry(this) + + private val savedStateRegistryController = + SavedStateRegistryController.create(this).apply { + performAttach() + } + + override val lifecycle: Lifecycle get() = lifecycleRegistry + override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry + + override fun onCreate() { + super.onCreate() + + savedStateRegistryController.performRestore(null) + lifecycleRegistry.currentState = Lifecycle.State.CREATED + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + val itemFlow = screensaverService.createItemFlow(lifecycleScope) + setContentView( + ComposeView(this).apply { + setViewTreeLifecycleOwner(this@WholphinDreamService) + setViewTreeSavedStateRegistryOwner(this@WholphinDreamService) + setContent { + var prefs by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + userPreferencesService.flow.collectLatest { prefs = it } + } + prefs?.let { prefs -> + WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { + val screensaverPrefs = + prefs.appPreferences.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + }, + ) + } + + override fun onDreamingStarted() { + super.onDreamingStarted() + lifecycleRegistry.currentState = Lifecycle.State.STARTED + } + + override fun onDreamingStopped() { + super.onDreamingStopped() + lifecycleRegistry.currentState = Lifecycle.State.DESTROYED + } +} 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 709abd6c..379b782b 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 @@ -997,6 +997,12 @@ sealed interface AppPreference { summaryOn = R.string.enabled, summaryOff = R.string.disabled, ) + + val ScreensaverSettings = + AppDestinationPreference( + title = R.string.screensaver_settings, + destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER), + ) } } @@ -1011,6 +1017,7 @@ val basicPreferences = AppPreference.RememberSelectedTab, AppPreference.SubtitleStyle, AppPreference.ThemeColors, + AppPreference.ScreensaverSettings, ), ), PreferenceGroup( @@ -1201,6 +1208,24 @@ val liveTvPreferences = AppPreference.LiveTvColorCodePrograms, ) +val screensaverPreferences = + listOf( + PreferenceGroup( + title = R.string.screensaver, + preferences = + listOf( + ScreensaverPreference.Enabled, + ScreensaverPreference.StartDelay, + ScreensaverPreference.Duration, + ScreensaverPreference.ShowClock, + ScreensaverPreference.Animate, + ScreensaverPreference.MaxAge, + ScreensaverPreference.ItemTypes, + ScreensaverPreference.Start, + ), + ), + ) + data class AppSwitchPreference( @get:StringRes override val title: Int, override val defaultValue: Boolean, @@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference( override val getter: (prefs: Pref) -> List, override val setter: (prefs: Pref, value: List) -> Pref, @param:StringRes val summary: Int? = null, - val toSharedPrefs: (T) -> String, - val fromSharedPrefs: (String) -> T?, ) : AppPreference> data class AppClickablePreference( 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 6adcdca4..bf8ab2a4 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 @@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException import androidx.datastore.core.Serializer import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.google.protobuf.InvalidProtocolBufferException +import org.jellyfin.sdk.model.api.BaseItemKind import java.io.InputStream import java.io.OutputStream import javax.inject.Inject @@ -120,6 +121,20 @@ class AppPreferencesSerializer colorCodePrograms = AppPreference.LiveTvColorCodePrograms.defaultValue }.build() + + screensaverPreference = + ScreensaverPreferences + .newBuilder() + .apply { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + showClock = ScreensaverPreference.ShowClock.defaultValue + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + }.build() }.build() advancedPreferences = @@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder photoPreferences = photoPreferences.toBuilder().apply(block).build() } +inline fun AppPreferences.updateScreensaverPreferences(block: ScreensaverPreferences.Builder.() -> Unit): AppPreferences = + updateInterfacePreferences { + screensaverPreference = screensaverPreference.toBuilder().apply(block).build() + } + fun SubtitlePreferences.Builder.resetSubtitles() { fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt new file mode 100644 index 00000000..1e1ad91c --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/ScreensaverPreference.kt @@ -0,0 +1,183 @@ +package com.github.damontecres.wholphin.preferences + +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.WholphinApplication +import org.jellyfin.sdk.model.api.BaseItemKind +import kotlin.time.Duration.Companion.milliseconds + +object ScreensaverPreference { + val Enabled = + AppSwitchPreference( + title = R.string.in_app_screensaver, + defaultValue = false, + getter = { it.interfacePreferences.screensaverPreference.enabled }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { enabled = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + const val DEFAULT_START_DELAY = 15 * 60_000L + private val startDelayValues = + listOf( + 30_000L, + 60_000L, + 2 * 60_000L, + 5 * 60_000L, + 10 * 60_000L, + 15 * 60_000L, + 30 * 60_000L, + 60 * 60_000L, + ) + val StartDelay = + AppSliderPreference( + title = R.string.start_after, + defaultValue = startDelayValues.indexOf(DEFAULT_START_DELAY).toLong(), + min = 0, + max = startDelayValues.size - 1L, + interval = 1, + getter = { + startDelayValues.indexOf(it.interfacePreferences.screensaverPreference.startDelay).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + startDelay = startDelayValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = startDelayValues.getOrNull(value.toInt()) ?: DEFAULT_START_DELAY + v.milliseconds.toString() + } else { + null + } + }, + ) + + const val DEFAULT_DURATION = 30_000L + private val durationValues = + listOf( + 15_000L, + 30_000L, + 60_000L, + 2 * 60_000L, + ) + val Duration = + AppSliderPreference( + title = R.string.duration, + defaultValue = durationValues.indexOf(DEFAULT_DURATION).toLong(), + min = 0, + max = durationValues.size - 1L, + interval = 1, + getter = { + durationValues.indexOf(it.interfacePreferences.screensaverPreference.duration).toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + duration = durationValues[value.toInt()] + } + }, + summarizer = { value -> + if (value != null) { + val v = durationValues.getOrNull(value.toInt()) ?: DEFAULT_DURATION + v.milliseconds.toString() + } else { + null + } + }, + ) + + val ShowClock = + AppSwitchPreference( + title = R.string.show_clock, + defaultValue = AppPreference.ShowClock.defaultValue, + getter = { it.interfacePreferences.screensaverPreference.showClock }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { showClock = value } + }, + summaryOn = R.string.yes, + summaryOff = R.string.no, + ) + + val Animate = + AppSwitchPreference( + title = R.string.animate, + defaultValue = true, + getter = { it.interfacePreferences.screensaverPreference.animate }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { animate = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + + const val DEFAULT_MAX_AGE = 16 + private val maxAgeValues = listOf(0, 5, 10, 13, 14, 16, 18, 21, -1) + val MaxAge = + AppSliderPreference( + title = R.string.max_age_rating, + defaultValue = maxAgeValues.indexOf(DEFAULT_MAX_AGE).toLong(), + min = 0, + max = maxAgeValues.size - 1L, + interval = 1, + getter = { + it.interfacePreferences.screensaverPreference.maxAgeFilter + .takeIf { it >= 0 } + ?.let { maxAgeValues.indexOf(it).toLong() } + ?: maxAgeValues.lastIndex.toLong() + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + maxAgeFilter = maxAgeValues[value.toInt()] + } + }, + summarizer = { value -> + when (value) { + null -> { + null + } + + maxAgeValues.lastIndex.toLong() -> { + WholphinApplication.instance.getString(R.string.no_max) + } + + 0L -> { + WholphinApplication.instance.getString(R.string.for_all_ages) + } + + else -> { + WholphinApplication.instance.getString( + R.string.up_to_age, + maxAgeValues[value.toInt()].toString(), + ) + } + } + }, + ) + + val ItemTypes = + AppMultiChoicePreference( + title = R.string.include_types, + summary = R.string.include_types_summary, + defaultValue = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + allValues = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.PHOTO), + displayValues = R.array.screensaver_item_types, + getter = { + it.interfacePreferences.screensaverPreference.itemTypesList.map { type -> + BaseItemKind.fromName(type) + } + }, + setter = { prefs, value -> + prefs.updateScreensaverPreferences { + clearItemTypes() + addAllItemTypes(value.map { it.serialName }) + } + }, + ) + + val Start = + AppClickablePreference( + title = R.string.start_screensaver, + ) +} 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 057ec550..23d6cef6 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 @@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.update import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateHomePagePreferences @@ -16,11 +17,13 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences import com.github.damontecres.wholphin.preferences.updateMpvOptions import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides +import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.util.Version import dagger.hilt.android.qualifiers.ApplicationContext +import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber import java.io.File import javax.inject.Inject @@ -246,4 +249,18 @@ suspend fun upgradeApp( } } } + + if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) { + appPreferences.updateData { + it.updateScreensaverPreferences { + startDelay = ScreensaverPreference.DEFAULT_START_DELAY + duration = ScreensaverPreference.DEFAULT_DURATION + animate = ScreensaverPreference.Animate.defaultValue + maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE + clearItemTypes() + addItemTypes(BaseItemKind.MOVIE.serialName) + addItemTypes(BaseItemKind.SERIES.serialName) + } + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index f0de9d24..5e6bfa8c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -37,9 +37,7 @@ class ImageUrlService fillHeight: Int? = null, ): String? = when (imageType) { - ImageType.BACKDROP, - ImageType.LOGO, - -> { + ImageType.LOGO -> { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { getItemImageUrl( itemId = seriesId, @@ -57,6 +55,27 @@ class ImageUrlService } } + ImageType.BACKDROP, + -> { + if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { + getItemImageUrl( + itemId = seriesId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else if (backdropTags.isNotEmpty()) { + getItemImageUrl( + itemId = itemId, + imageType = imageType, + fillWidth = fillWidth, + fillHeight = fillHeight, + ) + } else { + null + } + } + ImageType.THUMB -> { if (useSeriesForPrimary && parentThumbId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt new file mode 100644 index 00000000..ea806436 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -0,0 +1,224 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import android.view.WindowManager +import coil3.imageLoader +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.MainActivity +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.ui.components.CurrentItem +import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.util.ApiRequestPager +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.GetItemsRequestHandler +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.ItemSortBy +import org.jellyfin.sdk.model.api.request.GetItemsRequest +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.milliseconds + +@Singleton +class ScreensaverService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + @param:DefaultCoroutineScope private val scope: CoroutineScope, + private val api: ApiClient, + private val userPreferencesService: UserPreferencesService, + private val imageUrlService: ImageUrlService, + ) { + private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) + val state: StateFlow = _state + + private var waitJob: Job? = null + + init { + userPreferencesService.flow + .onEach { prefs -> + _state.update { + val enabled = + prefs.appPreferences.interfacePreferences.screensaverPreference.enabled + keepScreenOnInternal(enabled) + ScreensaverState(enabled, false, false, false) + } + }.launchIn(scope) + } + + fun pulse() { + waitJob?.cancel() + if (_state.value.enabled) { +// Timber.v("pulse") + _state.update { + if (!it.active) { + it.copy(active = false) + } else { + it + } + } + + if (!_state.value.paused) { + waitJob = + scope.launch(ExceptionHandler()) { + val startDelay = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.screensaverPreference.startDelay.milliseconds + delay(startDelay) + _state.update { + it.copy(active = true) + } + } + } + } + } + + fun start() { + _state.update { + it.copy( + enabledTemp = true, + active = true, + ) + } + } + + fun stop(cancelJob: Boolean) { + _state.update { + it.copy( + enabledTemp = false, + active = false, + ) + } + if (cancelJob) waitJob?.cancel() + } + + fun keepScreenOn(keep: Boolean) { + scope.launch { + val screensaverEnabled = _state.value.enabled + Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) + if (screensaverEnabled) { + // Page is requesting to keep screen on, so we don't wait to show the screensaver + _state.update { + it.copy(active = false, paused = keep) + } + if (!keep) { + pulse() + } + } else { + keepScreenOnInternal(keep) + } + } + } + + private suspend fun keepScreenOnInternal(keep: Boolean) = + withContext(Dispatchers.Main) { + val window = MainActivity.instance.window + if (keep) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + fun createItemFlow(scope: CoroutineScope): Flow = + flow { + val prefs = + userPreferencesService.flow + .first() + .appPreferences + .interfacePreferences.screensaverPreference + val maxAge = prefs.maxAgeFilter.takeIf { it >= 0 } + val itemTypes = prefs.itemTypesList.map { BaseItemKind.fromName(it) } + val request = + GetItemsRequest( + recursive = true, + includeItemTypes = itemTypes, + imageTypes = if (BaseItemKind.PHOTO in itemTypes) null else listOf(ImageType.BACKDROP), + sortBy = listOf(ItemSortBy.RANDOM), + maxOfficialRating = maxAge?.toString(), + hasParentalRating = maxAge?.let { true }, + ) + val pager = + ApiRequestPager(api, request, GetItemsRequestHandler, scope).init() + Timber.v("Got %s items", pager.size) + var index = 0 + if (pager.isEmpty()) { + emit(null) + } else { + while (true) { + val item = pager.getBlocking(index) + Timber.v("Next index=%s, item=%s", index, item?.id) + if (item != null) { + val backdropUrl = + if (item.type == BaseItemKind.PHOTO) { + api.libraryApi.getDownloadUrl(item.id) + } else { + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) + } + val title = + if (item.type == BaseItemKind.PHOTO) { + item.data.premiereDate?.let { + formatDate(it.toLocalDate()) + } + } else { + item.title + } + val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) + if (backdropUrl != null) { + val result = + context.imageLoader + .enqueue( + ImageRequest + .Builder(context) + .data(backdropUrl) + .build(), + ).job + .await() + try { + emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) + } catch (_: CancellationException) { + break + } + val duration = + userPreferencesService + .getCurrent() + .appPreferences + .interfacePreferences.screensaverPreference.duration.milliseconds + delay(duration) + } + } + index++ + } + } + }.cancellable() + } + +data class ScreensaverState( + val enabled: Boolean, + val enabledTemp: Boolean, + val active: Boolean, + val paused: Boolean, +) { + val show get() = (enabled || enabledTemp) && active && !paused +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt index 90d68e52..67e940b5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UserPreferencesService.kt @@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +16,8 @@ class UserPreferencesService private val serverRepository: ServerRepository, private val preferencesDataStore: DataStore, ) { + val flow = preferencesDataStore.data.map { UserPreferences(it) } + suspend fun getCurrent(): UserPreferences = serverRepository.currentUserDto.value!!.configuration.let { userConfig -> val appPrefs = preferencesDataStore.data.firstOrNull() ?: AppPreferences.getDefaultInstance() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index c997efd1..f6439bf1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.ContextWrapper import android.media.AudioManager import android.view.KeyEvent -import android.view.WindowManager import android.widget.Toast import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.basicMarquee @@ -296,7 +295,7 @@ fun Arrangement.spacedByWithFooter(space: Dp) = } /** - * Tries to find the [Activity] for the given [Context]. Often used for [keepScreenOn]. + * Tries to find the [Activity] for the given [Context] */ fun Context.findActivity(): Activity? { if (this is Activity) { @@ -310,18 +309,6 @@ fun Context.findActivity(): Activity? { return null } -/** - * Keep the screen on for an [Activity]. Often used with [findActivity]. - */ -fun Activity.keepScreenOn(keep: Boolean) { - Timber.v("Keep screen on: $keep") - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } -} - /** * Selectively log errors from Coil image loading. * diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt new file mode 100644 index 00000000..e55a2aa6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -0,0 +1,214 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RadialGradientShader +import androidx.compose.ui.graphics.Shader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.datastore.core.DataStore +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.useExistingImageAsPlaceholder +import coil3.request.ImageRequest +import coil3.request.transitionFactory +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.ui.AppColors +import com.github.damontecres.wholphin.ui.CrossFadeFactory +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_ALPHA +import com.github.damontecres.wholphin.ui.nav.TOP_SCRIM_END_FRACTION +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +@HiltViewModel +class ScreensaverViewModel + @Inject + constructor( + private val screensaverService: ScreensaverService, + val preferencesDataStore: DataStore, + ) : ViewModel() { + val currentItem = screensaverService.createItemFlow(viewModelScope) + } + +data class CurrentItem( + val item: BaseItem, + val backdropUrl: String, + val logoUrl: String?, + val title: String, +) + +@Composable +fun AppScreensaver( + prefs: AppPreferences, + modifier: Modifier = Modifier, + viewModel: ScreensaverViewModel = hiltViewModel(), +) { + val currentItem by viewModel.currentItem.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = prefs.interfacePreferences.screensaverPreference.showClock, + duration = prefs.interfacePreferences.screensaverPreference.duration.milliseconds, + animate = prefs.interfacePreferences.screensaverPreference.animate, + modifier = modifier, + ) +} + +@OptIn(ExperimentalCoilApi::class) +@Composable +fun AppScreensaverContent( + currentItem: CurrentItem?, + showClock: Boolean, + duration: Duration, + animate: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier + .background(Color.Black), + ) { + val infiniteTransition = rememberInfiniteTransition() + val scale by infiniteTransition.animateFloat( + 1f, + if (animate) 1.1f else 1f, + infiniteRepeatable( + tween( + durationMillis = duration.inWholeMilliseconds.toInt(), + delayMillis = 500, + easing = LinearEasing, + ), + repeatMode = RepeatMode.Reverse, + ), + ) + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.backdropUrl) + .transitionFactory(CrossFadeFactory(2000.milliseconds)) + .useExistingImageAsPlaceholder(true) + .build(), + contentDescription = null, + modifier = + Modifier + .fillMaxSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + + var logoError by remember(currentItem) { mutableStateOf(false) } + val alignment = listOf(Alignment.BottomStart, Alignment.BottomEnd).random() + if (!logoError) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(currentItem?.logoUrl) + .transitionFactory(CrossFadeFactory(750.milliseconds)) + .build(), + contentDescription = "Logo", + onError = { + logoError = true + }, + modifier = + Modifier + .align(alignment) + .size(width = 240.dp, height = 120.dp) + .padding(16.dp), + ) + } else { + Box( + modifier = + Modifier + .align(alignment) + .padding(16.dp) + .fillMaxWidth(.5f) + .fillMaxHeight(.3f), + ) { + Text( + text = currentItem?.title ?: "", + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(alignment), + ) + } + } + + val largeRadialGradient = + remember { + object : ShaderBrush() { + override fun createShader(size: Size): Shader { + val biggerDimension = maxOf(size.height, size.width) + return RadialGradientShader( + colors = listOf(Color.Transparent, AppColors.TransparentBlack25), + center = size.center, + radius = biggerDimension / 1.5f, + colorStops = listOf(0f, 0.85f), + ) + } + } + } + Canvas(Modifier.fillMaxSize()) { + drawRect( + brush = largeRadialGradient, + blendMode = BlendMode.Multiply, + ) + if (showClock) { + // Add scrim to make clock more readable + drawRect( + brush = + Brush.verticalGradient( + colorStops = + arrayOf( + 0f to Color.Black.copy(alpha = TOP_SCRIM_ALPHA), + TOP_SCRIM_END_FRACTION to Color.Transparent, + ), + ), + blendMode = BlendMode.Multiply, + ) + } + } + if (showClock) { + TimeDisplay() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt index 0e3d5a43..5f01f20d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/ApplicationContent.kt @@ -55,8 +55,8 @@ import javax.inject.Inject import kotlin.time.Duration.Companion.milliseconds // Top scrim configuration for text readability (clock, season tabs) -private const val TOP_SCRIM_ALPHA = 0.55f -private const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height +const val TOP_SCRIM_ALPHA = 0.55f +const val TOP_SCRIM_END_FRACTION = 0.25f // Fraction of backdrop image height @HiltViewModel class ApplicationContentViewModel diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt deleted file mode 100644 index 7fc97366..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/AmbientPlayerListener.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.damontecres.wholphin.ui.playback - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext -import androidx.media3.common.Player -import androidx.media3.common.Player.Listener -import com.github.damontecres.wholphin.ui.findActivity -import com.github.damontecres.wholphin.ui.keepScreenOn - -/** - * Starts a [Player.Listener] that ensures the screen stays on without a screen saber during playback - * - * This will clean up the listener when disposed - */ -@Composable -fun AmbientPlayerListener(player: Player) { - val context = LocalContext.current - DisposableEffect(player) { - val listener = - object : Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - context.findActivity()?.keepScreenOn(isPlaying) - } - } - player.addListener(listener) - onDispose { - player.removeListener(listener) - context.findActivity()?.keepScreenOn(false) - } - } -} 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 0313a8c5..1459736d 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 @@ -186,7 +186,6 @@ fun PlaybackPageContent( } } - AmbientPlayerListener(player) var contentScale by remember(playerBackend) { mutableStateOf( if (playerBackend == PlayerBackend.MPV) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index 4d44ff80..0d23f4bf 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.RefreshRateService +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -140,6 +141,7 @@ class PlaybackViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val imageUrlService: ImageUrlService, + private val screensaverService: ScreensaverService, @Assisted private val destination: Destination, ) : ViewModel(), Player.Listener, @@ -189,7 +191,10 @@ class PlaybackViewModel init { viewModelScope.launchIO { - addCloseable { disconnectPlayer() } + addCloseable { + screensaverService.keepScreenOn(false) + disconnectPlayer() + } init() } } @@ -1424,6 +1429,10 @@ class PlaybackViewModel } } } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + screensaverService.keepScreenOn(isPlaying) + } } data class PlayerState( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt index 9b9bcf9c..1762f916 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/ComposablePreference.kt @@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.launch import java.io.File -import java.util.SortedSet @Suppress("UNCHECKED_CAST") @Composable @@ -226,7 +225,7 @@ fun ComposablePreference( } is AppMultiChoicePreference<*, *> -> { - val values = stringArrayResource(preference.displayValues).toSortedSet() + val values = stringArrayResource(preference.displayValues) val summary = preference.summary?.let { stringResource(it) } ?: preference.summary(context, value) @@ -237,12 +236,15 @@ fun ComposablePreference( list } MultiChoicePreference( - possibleValues = values as SortedSet, + possibleValues = preference.allValues, selectedValues = selectedValues, title = title, summary = summary, onValueChange = { - onValueChange.invoke(selectedValues.toList() as T) + onValueChange.invoke(it.toList() as T) + }, + valueDisplay = { index, _ -> + Text(values[index]) }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt index a4686b10..bb34207f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/MultiChoicePreference.kt @@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup fun MultiChoicePreference( title: String, summary: String?, - possibleValues: Set, + possibleValues: List, selectedValues: Set, onValueChange: (List) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, - valueDisplay: @Composable (item: T) -> Unit = { Text(it.toString()) }, + valueDisplay: @Composable (index: Int, item: T) -> Unit = { _, item -> Text(item.toString()) }, ) { // val values = stringArrayResource(preference.displayValues).toList() // val summary = @@ -59,7 +59,7 @@ fun MultiChoicePreference( items = possibleValues.mapIndexed { index, item -> DialogItem( - headlineContent = { valueDisplay.invoke(item) }, + headlineContent = { valueDisplay.invoke(index, item) }, trailingContent = { Switch( checked = selectedValues.contains(item), 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 c9f6f529..454a0266 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 @@ -35,6 +35,7 @@ enum class PreferenceScreenOption { ADVANCED, EXO_PLAYER, MPV, + SCREENSAVER, ; companion object { 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 b04293f8..d9f1fb4d 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,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.PlayerBackend +import com.github.damontecres.wholphin.preferences.ScreensaverPreference import com.github.damontecres.wholphin.preferences.advancedPreferences import com.github.damontecres.wholphin.preferences.basicPreferences +import com.github.damontecres.wholphin.preferences.screensaverPreferences import com.github.damontecres.wholphin.preferences.updatePlaybackPreferences import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.UpdateChecker @@ -128,6 +130,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.MPV -> MpvPreferences + PreferenceScreenOption.SCREENSAVER -> screensaverPreferences } val screenTitle = when (preferenceScreenOption) { @@ -135,6 +138,7 @@ fun PreferencesContent( PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.MPV -> R.string.mpv_options + PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings } var visible by remember { mutableStateOf(false) } @@ -283,7 +287,9 @@ fun PreferencesContent( if (movementSounds) playOnClickSound(context) if (release != null && updateAvailable) { release?.let { - viewModel.navigationManager.navigateTo(Destination.UpdateApp) + viewModel.navigationManager.navigateTo( + Destination.UpdateApp, + ) } } else { updateVM.init(preferences.updateUrl) @@ -313,7 +319,8 @@ fun PreferencesContent( val summary = remember(cacheUsage) { cacheUsage.let { - val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT + val diskMB = + it.imageDiskUsed / AppPreference.MEGA_BIT val memoryUsedMB = it.imageMemoryUsed / AppPreference.MEGA_BIT val memoryMaxMB = @@ -392,7 +399,10 @@ fun PreferencesContent( seerrDialogMode = when (val conn = seerrConnection) { is SeerrConnectionStatus.Error -> { - SeerrDialogMode.Error(conn.serverUrl, conn.ex) + SeerrDialogMode.Error( + conn.serverUrl, + conn.ex, + ) } SeerrConnectionStatus.NotConfigured -> { @@ -409,9 +419,19 @@ fun PreferencesContent( modifier = Modifier, summary = when (seerrConnection) { - is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) - SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) - is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) + is SeerrConnectionStatus.Error -> { + stringResource(R.string.voice_error_server) + } + + SeerrConnectionStatus.NotConfigured -> { + stringResource( + R.string.add_server, + ) + } + + is SeerrConnectionStatus.Success -> { + stringResource(R.string.enabled) + } }, onLongClick = {}, interactionSource = interactionSource, @@ -434,6 +454,19 @@ fun PreferencesContent( ) } + ScreensaverPreference.Start -> { + ClickPreference( + title = stringResource(pref.title), + onClick = { + viewModel.screensaverService.start() + }, + modifier = Modifier, + summary = pref.summary(context, null), + onLongClick = {}, + interactionSource = interactionSource, + ) + } + else -> { val value = pref.getter.invoke(preferences) ComposablePreference( @@ -597,6 +630,7 @@ fun PreferencesPage( PreferenceScreenOption.ADVANCED, PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.MPV, + PreferenceScreenOption.SCREENSAVER, -> { PreferencesContent( initialPreferences, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt index bec82e58..58f9a5e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesViewModel.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.launchIO @@ -35,6 +36,7 @@ class PreferencesViewModel val preferenceDataStore: DataStore, val navigationManager: NavigationManager, val backdropService: BackdropService, + val screensaverService: ScreensaverService, private val rememberTabManager: RememberTabManager, private val serverRepository: ServerRepository, private val seerrServerRepository: SeerrServerRepository, 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 index 1c121ef6..f35bc102 100644 --- 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 @@ -14,7 +14,6 @@ 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 @@ -63,8 +62,6 @@ 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 @@ -208,12 +205,6 @@ fun SlideshowPage( 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) } 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 index 4d2d0662..992659ca 100644 --- 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 @@ -18,6 +18,7 @@ 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.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.launchIO @@ -67,6 +68,7 @@ class SlideshowViewModel private val serverRepository: ServerRepository, private val imageUrlService: ImageUrlService, private val userPreferencesService: UserPreferencesService, + private val screensaverService: ScreensaverService, @Assisted val slideshowSettings: Destination.Slideshow, ) : ViewModel(), Player.Listener { @@ -110,6 +112,7 @@ class SlideshowViewModel init { addCloseable { + screensaverService.keepScreenOn(false) player.removeListener(this@SlideshowViewModel) player.release() } @@ -282,6 +285,7 @@ class SlideshowViewModel private var slideshowJob: Job? = null fun startSlideshow() { + screensaverService.keepScreenOn(true) _slideshow.update { SlideshowState(enabled = true, paused = false) } @@ -295,6 +299,7 @@ class SlideshowViewModel } fun stopSlideshow() { + screensaverService.keepScreenOn(false) slideshowJob?.cancel() _slideshow.update { SlideshowState(enabled = false, paused = false) diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 737dbb57..8d6034b1 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -144,6 +144,16 @@ enum BackdropStyle{ BACKDROP_NONE = 2; } +message ScreensaverPreferences{ + int64 start_delay = 1; + int64 duration = 2; + bool animate = 3; + int32 max_age_filter = 4; + bool enabled = 5; + repeated string item_types = 6; + bool show_clock = 7; +} + message InterfacePreferences { ThemeSongVolume play_theme_songs = 1; bool remember_selected_tab = 2; @@ -155,6 +165,7 @@ message InterfacePreferences { LiveTvPreferences live_tv_preferences = 8; BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; + ScreensaverPreferences screensaver_preference = 11; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 93476b74..65afe072 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -276,6 +276,11 @@ %d second %d seconds
+ + %s minutes + %s minute + %s minutes + Movies @@ -522,6 +527,19 @@ Display presets Built-in presets to quickly style all rows Choose rows and images on the home page + Start after + Animate + Max age rating + No max + For all ages + Up to age %s + Screensaver + Screensaver settings + Start screensaver + Duration + Photos + Include types + What types of items to show images for Fit Crop Fill @@ -673,10 +691,17 @@ No remote subtitles were found Present + Use in-app screensaver @string/backdrop_style_dynamic @string/backdrop_style_image @string/none + + @string/movies + @string/tv_shows + @string/photos + + From 9d857bc59e04e9b0d385e5d184bc0c8798fad591 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:08:09 -0500 Subject: [PATCH 160/176] Move new feature requests to discussions (#984) ## Description Update the issue templates to direct new feature requests to GitHub Discussions ### Related issues Closes #982 --- .../ideas.yml} | 4 +--- .github/ISSUE_TEMPLATE/00-bug.yml | 7 ++++--- .github/ISSUE_TEMPLATE/01-playback.yml | 8 +++++--- .github/ISSUE_TEMPLATE/03-question.yml | 15 --------------- .github/ISSUE_TEMPLATE/config.yml | 8 ++++++++ 5 files changed, 18 insertions(+), 24 deletions(-) rename .github/{ISSUE_TEMPLATE/02-new-feature.yml => DISCUSSION_TEMPLATE/ideas.yml} (75%) delete mode 100644 .github/ISSUE_TEMPLATE/03-question.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/02-new-feature.yml b/.github/DISCUSSION_TEMPLATE/ideas.yml similarity index 75% rename from .github/ISSUE_TEMPLATE/02-new-feature.yml rename to .github/DISCUSSION_TEMPLATE/ideas.yml index 500beea4..5d1b4346 100644 --- a/.github/ISSUE_TEMPLATE/02-new-feature.yml +++ b/.github/DISCUSSION_TEMPLATE/ideas.yml @@ -1,6 +1,4 @@ -name: "Feature Request" -description: Request a new feature -title: "[FEA] - " +title: "<title>" labels: [ "enhancement" ] diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index f67a7db7..2f4244a0 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -1,9 +1,10 @@ name: "Bug Report" description: Report a bug -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug" ] +type: "Bug" body: - type: textarea id: description @@ -28,7 +29,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -36,7 +37,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 2dc73ff6..978c1eba 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -1,9 +1,10 @@ name: "Playback Problem" description: Report an issue with playback -title: "[BUG] - <title>" +title: "<title>" labels: [ "bug", "playback" ] +type: "Bug" body: - type: textarea id: description @@ -17,6 +18,7 @@ body: attributes: label: "Media info" description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) + placeholder: Use mediainfo or "Send media info to server" in the app to gather this validations: required: true - type: dropdown @@ -35,7 +37,7 @@ body: attributes: label: "App Version" description: What version of the app? - placeholder: "v0.3.0" + placeholder: "v0.5.1" validations: required: true - type: input @@ -43,7 +45,7 @@ body: attributes: label: "Server Version" description: What version of the server? - placeholder: "10.11.3" + placeholder: "10.11.6" validations: required: true - type: input diff --git a/.github/ISSUE_TEMPLATE/03-question.yml b/.github/ISSUE_TEMPLATE/03-question.yml deleted file mode 100644 index bd10bd46..00000000 --- a/.github/ISSUE_TEMPLATE/03-question.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: "Question" -description: Ask a question -title: "[QST] - <title>" -labels: [ - "question" -] -body: - - type: textarea - id: description - attributes: - label: "Question" - description: Please enter your question - placeholder: "How do I...?" - validations: - required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..1ba6fdae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: New feature / UI suggestion + url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas + about: Please request new features here + - name: Questions + url: https://github.com/damontecres/Wholphin/discussions/new?category=general + about: Please ask questions here From 138efcde004bcfe7b7972936d0b06a54f9debcf2 Mon Sep 17 00:00:00 2001 From: Damontecres <damontecres@gmail.com> Date: Wed, 25 Feb 2026 15:09:46 -0500 Subject: [PATCH 161/176] Fix issue templates [skip ci] --- .github/ISSUE_TEMPLATE/00-bug.yml | 1 - .github/ISSUE_TEMPLATE/01-playback.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml index 2f4244a0..1aab31da 100644 --- a/.github/ISSUE_TEMPLATE/00-bug.yml +++ b/.github/ISSUE_TEMPLATE/00-bug.yml @@ -4,7 +4,6 @@ title: "<title>" labels: [ "bug" ] -type: "Bug" body: - type: textarea id: description diff --git a/.github/ISSUE_TEMPLATE/01-playback.yml b/.github/ISSUE_TEMPLATE/01-playback.yml index 978c1eba..34dc317b 100644 --- a/.github/ISSUE_TEMPLATE/01-playback.yml +++ b/.github/ISSUE_TEMPLATE/01-playback.yml @@ -4,7 +4,6 @@ title: "<title>" labels: [ "bug", "playback" ] -type: "Bug" body: - type: textarea id: description From 7f10b5a25ee6a4fe9c008aa5148041e371766770 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 15:15:02 -0500 Subject: [PATCH 162/176] Use Q&A for questions [skip ci] --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1ba6fdae..2ee91c39 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/damontecres/Wholphin/discussions/new?category=ideas about: Please request new features here - name: Questions - url: https://github.com/damontecres/Wholphin/discussions/new?category=general + url: https://github.com/damontecres/Wholphin/discussions/new?category=q-a about: Please ask questions here From 37d52ef57e29c29fac53da92f707767ca88bf578 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 25 Feb 2026 20:38:16 -0500 Subject: [PATCH 163/176] Update readme images to v0.5.1 --- README.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1b3a0708..6a3c4c65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin </p> -<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> +![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5) ## Features @@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin ### Customized home page ![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) - ### Movie library browsing -<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> +![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3) ### Movie page -<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> +![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6) ### Series page -<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> +![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429) + +### Genres in library +![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498) + ### Playlist -<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> +![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc) From 9b995bf6baf299fe87e1643fd8b07716b082c09b Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 26 Feb 2026 15:48:39 -0500 Subject: [PATCH 164/176] Fix some IO related crashes (#1008) ## Description - Catch network exceptions when querying to populate the nav drawer - Perform screensaver work on IO thread - Fix possible crash if screensaver service runs while regular activity is destroyed - Fix genre images & backdrop images not loading This PR also adds an Jellyfin SDK `ApiClient` wrapper to push all network calls onto the IO thread. And tries to use a more strict network-on-main policy for debug builds. ### Related issues Fixes #994 Fixes #1001 Should fix #1003 ### Testing Emulator with simulated exception throws ## Screenshots N/A ## AI or LLM usage None --- .../damontecres/wholphin/MainActivity.kt | 18 ++++ .../wholphin/WholphinApplication.kt | 28 +++--- .../wholphin/data/ServerRepository.kt | 4 +- .../wholphin/services/BackdropService.kt | 2 +- .../wholphin/services/ImageUrlService.kt | 4 +- .../wholphin/services/NavDrawerService.kt | 6 ++ .../wholphin/services/ScreensaverService.kt | 26 +++--- .../wholphin/services/hilt/AppModule.kt | 5 +- .../damontecres/wholphin/ui/Extensions.kt | 7 -- .../wholphin/ui/components/GenreCardGrid.kt | 1 + .../util/CoroutineContextApiClient.kt | 88 +++++++++++++++++++ 11 files changed, 147 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.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 7b6f0c9b..5a6e446f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -79,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.serializer.toUUIDOrNull @@ -165,6 +169,20 @@ class MainActivity : AppCompatActivity() { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } + screensaverService.keepScreenOn + .onEach { keepScreenOn -> + Timber.v("keepScreenOn: %s", keepScreenOn) + withContext(Dispatchers.Main) { + if (keepScreenOn) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + }.catch { ex -> + Timber.e(ex, "Error with keepScreenOn") + }.launchIn(lifecycleScope) + viewModel.appStart() setContent { val appPreferences by userPreferencesDataStore.data.collectAsState(null) diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index cf2c159a..900fc4c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -3,7 +3,6 @@ package com.github.damontecres.wholphin import android.app.Application import android.os.Build import android.os.StrictMode -import android.os.StrictMode.ThreadPolicy import android.util.Log import androidx.compose.runtime.Composer import androidx.compose.runtime.ExperimentalComposeRuntimeApi @@ -27,16 +26,6 @@ class WholphinApplication : init { instance = this - if (BuildConfig.DEBUG) { - StrictMode.setThreadPolicy( - ThreadPolicy - .Builder() - .detectNetwork() - .penaltyLog() - .build(), - ) - } - if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) } else { @@ -64,6 +53,23 @@ class WholphinApplication : override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy + .Builder() + .detectNetwork() + .penaltyLog() + .penaltyDeathOnNetwork() + .build(), + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy + .Builder() + .detectAll() + .penaltyLog() + .build(), + ) + } OkHttp.initialize(this) initAcra { buildConfigClass = BuildConfig::class.java diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index f013525b..610d0b88 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -42,8 +42,6 @@ class ServerRepository val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, ) { - private val sharedPreferences = getServerSharedPreferences(context) - private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -107,7 +105,7 @@ class ServerRepository _current.value = CurrentUser(updatedServer, updatedUser) _currentUserDto.value = userDto } - sharedPreferences.edit(true) { + getServerSharedPreferences(context).edit(true) { putString(SERVER_URL_KEY, updatedServer.url) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt index e082c5fa..e0c48cca 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/BackdropService.kt @@ -52,7 +52,7 @@ class BackdropService if (item.type == BaseItemKind.GENRE) { item.imageUrlOverride } else { - imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! + imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } submit(item.id.toString(), imageUrl) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 5e6bfa8c..1dd642b0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -28,11 +28,11 @@ class ImageUrlService itemType: BaseItemKind, seriesId: UUID?, useSeriesForPrimary: Boolean, - imageTags: Map<ImageType, String?>, imageType: ImageType, + imageTags: Map<ImageType, String?>, + backdropTags: List<String>, parentThumbId: UUID? = null, parentBackdropId: UUID? = null, - backdropTags: List<String> = emptyList(), fillWidth: Int? = null, fillHeight: Int? = null, ): String? = diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index adb21ff7..6b6111e4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -11,11 +11,13 @@ import com.github.damontecres.wholphin.ui.main.settings.Library import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -60,7 +62,11 @@ class NavDrawerService if (user != null && userDto != null && user.id == userDto.id) { updateNavDrawer(user, userDto) } + }.catch { ex -> + Timber.e(ex, "Error updating nav drawer") + showToast(context, "Error fetching user's views") }.launchIn(coroutineScope) + seerrServerRepository.active .onEach { discoverActive -> _state.update { it.copy(discoverEnabled = discoverActive) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt index ea806436..5d73ec21 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt @@ -1,13 +1,12 @@ package com.github.damontecres.wholphin.services import android.content.Context -import android.view.WindowManager import coil3.imageLoader import coil3.request.ImageRequest -import com.github.damontecres.wholphin.MainActivity import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope import com.github.damontecres.wholphin.ui.components.CurrentItem import com.github.damontecres.wholphin.ui.formatDate +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.util.ApiRequestPager import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler @@ -23,11 +22,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.model.api.BaseItemKind @@ -52,6 +51,8 @@ class ScreensaverService private val _state = MutableStateFlow(ScreensaverState(false, false, false, false)) val state: StateFlow<ScreensaverState> = _state + val keepScreenOn = MutableStateFlow(false) + private var waitJob: Job? = null init { @@ -114,7 +115,7 @@ class ScreensaverService } fun keepScreenOn(keep: Boolean) { - scope.launch { + scope.launchDefault { val screensaverEnabled = _state.value.enabled Timber.d("Keep screen on: %s, screensaverEnabled=%s", keep, screensaverEnabled) if (screensaverEnabled) { @@ -131,15 +132,9 @@ class ScreensaverService } } - private suspend fun keepScreenOnInternal(keep: Boolean) = - withContext(Dispatchers.Main) { - val window = MainActivity.instance.window - if (keep) { - window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } else { - window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) - } - } + private fun keepScreenOnInternal(keep: Boolean) { + keepScreenOn.update { keep } + } fun createItemFlow(scope: CoroutineScope): Flow<CurrentItem?> = flow { @@ -186,7 +181,7 @@ class ScreensaverService } val logoUrl = imageUrlService.getItemImageUrl(item, ImageType.LOGO) if (backdropUrl != null) { - val result = + try { context.imageLoader .enqueue( ImageRequest @@ -195,7 +190,6 @@ class ScreensaverService .build(), ).job .await() - try { emit(CurrentItem(item, backdropUrl, logoUrl, title ?: "")) } catch (_: CancellationException) { break @@ -211,7 +205,7 @@ class ScreensaverService index++ } } - }.cancellable() + }.flowOn(Dispatchers.Default).cancellable() } data class ScreensaverState( 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 52c37413..546636e5 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 @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.RememberTabManager import dagger.Module @@ -111,12 +112,12 @@ object AppModule { @Singleton fun okHttpFactory( @StandardOkHttpClient okHttpClient: OkHttpClient, - ) = OkHttpFactory(okHttpClient) + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) @Provides @Singleton fun jellyfin( - okHttpFactory: OkHttpFactory, + okHttpFactory: CoroutineContextApiClientFactory, @ApplicationContext context: Context, clientInfo: ClientInfo, deviceInfo: DeviceInfo, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt index f6439bf1..865c6d29 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Extensions.kt @@ -45,7 +45,6 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.Response import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult -import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.extensions.ticks import timber.log.Timber import java.util.UUID @@ -429,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems( useSeriesForPrimary: Boolean, ) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } -@Composable -fun rememberBackDropImage(item: BaseItem): String? { - val imageUrlService = LocalImageUrlService.current - return remember(item) { imageUrlService.getItemImageUrl(item, ImageType.BACKDROP) } -} - /** * Check if this, coalescing nulls to zero, is greater than that */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e1e28f0c..d42eea5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -209,6 +209,7 @@ suspend fun getGenreImageMap( imageType = ImageType.BACKDROP, imageTags = item.imageTags.orEmpty(), fillWidth = cardWidthPx, + backdropTags = item.backdropImageTags.orEmpty(), ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt new file mode 100644 index 00000000..3ea6bdf9 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CoroutineContextApiClient.kt @@ -0,0 +1,88 @@ +package com.github.damontecres.wholphin.util + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.ApiClientFactory +import org.jellyfin.sdk.api.client.HttpClientOptions +import org.jellyfin.sdk.api.client.HttpMethod +import org.jellyfin.sdk.api.client.RawResponse +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.api.sockets.SocketApi +import org.jellyfin.sdk.api.sockets.SocketConnection +import org.jellyfin.sdk.api.sockets.SocketConnectionFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import kotlin.coroutines.CoroutineContext + +/** + * Wraps [ApiClient.request] with the given [CoroutineContext] + */ +class CoroutineContextApiClient( + private val client: ApiClient, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClient() { + override val baseUrl: String? + get() = client.baseUrl + override val accessToken: String? + get() = client.accessToken + override val clientInfo: ClientInfo + get() = client.clientInfo + override val deviceInfo: DeviceInfo + get() = client.deviceInfo + override val httpClientOptions: HttpClientOptions + get() = client.httpClientOptions + override val webSocket: SocketApi + get() = client.webSocket + + override fun update( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) { + client.update(baseUrl, accessToken, clientInfo, deviceInfo) + } + + override suspend fun request( + method: HttpMethod, + pathTemplate: String, + pathParameters: Map<String, Any?>, + queryParameters: Map<String, Any?>, + requestBody: Any?, + ): RawResponse = + withContext(coroutineContext) { + client.request( + method, + pathTemplate, + pathParameters, + queryParameters, + requestBody, + ) + } +} + +class CoroutineContextApiClientFactory( + private val factory: OkHttpFactory, + private val coroutineContext: CoroutineContext = Dispatchers.IO, +) : ApiClientFactory, + SocketConnectionFactory { + override fun create( + baseUrl: String?, + accessToken: String?, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + httpClientOptions: HttpClientOptions, + socketConnectionFactory: SocketConnectionFactory, + ): ApiClient = + CoroutineContextApiClient( + factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory), + coroutineContext, + ) + + override fun create( + clientOptions: HttpClientOptions, + scope: CoroutineScope, + ): SocketConnection = factory.create(clientOptions, scope) +} From b65e55f4ed6c9a6ef428d74bd801b365e56e206a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Thu, 26 Feb 2026 20:27:22 -0500 Subject: [PATCH 165/176] A few small UI fixes (#1012) ## Description - Restores corner text for episode cards - Always show end time on playback overlay instead of it being controlled by the "Show Clock" setting - Better logic to determine person's role in media ### Related issues Fixes #1010 Fixes #1004 Fixes #975 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/model/BaseItem.kt | 6 ++- .../damontecres/wholphin/data/model/Person.kt | 51 ++++++++++++++++++- .../wholphin/services/PeopleFavorites.kt | 12 ++++- .../ui/detail/series/SeriesViewModel.kt | 2 +- .../wholphin/ui/playback/PlaybackOverlay.kt | 41 ++++++++------- app/src/main/res/values/strings.xml | 13 +++++ 6 files changed, 99 insertions(+), 26 deletions(-) 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 950e9b40..e88673c1 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 @@ -95,7 +95,11 @@ data class BaseItem( data.indexNumber?.let { "E$it" } ?: data.premiereDate?.let(::formatDateTime), episodeUnplayedCornerText = - if (type == BaseItemKind.SERIES || type == BaseItemKind.SEASON || type == BaseItemKind.BOX_SET) { + if (type == BaseItemKind.SERIES || + type == BaseItemKind.SEASON || + type == BaseItemKind.EPISODE || + type == BaseItemKind.BOX_SET + ) { data.indexNumber?.let { "E$it" } ?: data.userData ?.unplayedItemCount diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt index c12d37b5..ac4a4710 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/Person.kt @@ -1,6 +1,10 @@ package com.github.damontecres.wholphin.data.model +import android.content.Context +import androidx.annotation.StringRes import androidx.compose.runtime.Stable +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.isNotNullOrBlank import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.UUID @@ -19,19 +23,21 @@ data class Person( ) { companion object { fun fromDto( + context: Context, dto: BaseItemPerson, api: ApiClient, ): Person = Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = false, ) fun fromDto( + context: Context, dto: BaseItemPerson, favorite: Boolean, api: ApiClient, @@ -39,10 +45,51 @@ data class Person( Person( id = dto.id, name = dto.name, - role = dto.role, + role = personRole(context, dto.role, dto.type), type = dto.type, imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), favorite = favorite, ) } } + +private fun personRole( + context: Context, + role: String?, + type: PersonKind, +): String? = + if (type == PersonKind.ACTOR || type == PersonKind.GUEST_STAR || type == PersonKind.UNKNOWN) { + role + } else if (role.equals(type.name, ignoreCase = true)) { + type.stringRes?.let { context.getString(it) } + } else if (role.isNotNullOrBlank() && role.lowercase().contains(type.name.lowercase())) { + role + } else { + listOfNotNull( + role?.takeIf { it.isNotNullOrBlank() }, + type.stringRes?.let { context.getString(it) }, + ).takeIf { it.isNotEmpty() }?.joinToString(" - ") + } + +@get:StringRes +private val PersonKind.stringRes: Int? + get() = + when (this) { + PersonKind.UNKNOWN -> R.string.unknown + PersonKind.ACTOR -> R.string.actor + PersonKind.DIRECTOR -> R.string.director + PersonKind.COMPOSER -> R.string.composer + PersonKind.WRITER -> R.string.writer + PersonKind.GUEST_STAR -> R.string.guest_star + PersonKind.PRODUCER -> R.string.producer + PersonKind.CONDUCTOR -> R.string.conductor + PersonKind.LYRICIST -> R.string.lyricist + PersonKind.ARRANGER -> R.string.arranger + PersonKind.ENGINEER -> R.string.engineer + PersonKind.MIXER -> R.string.mixer + PersonKind.REMIXER -> R.string.mixer + PersonKind.CREATOR -> R.string.creator + PersonKind.ARTIST -> R.string.artist + PersonKind.ALBUM_ARTIST -> R.string.artist + else -> null + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt index b4967b03..6a35dda5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/PeopleFavorites.kt @@ -1,8 +1,10 @@ package com.github.damontecres.wholphin.services +import android.content.Context import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.ui.letNotEmpty +import dagger.hilt.android.qualifiers.ApplicationContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.itemsApi import javax.inject.Inject @@ -12,6 +14,7 @@ import javax.inject.Singleton class PeopleFavorites @Inject constructor( + @param:ApplicationContext private val context: Context, private val api: ApiClient, ) { suspend fun getPeopleFor(item: BaseItem): List<Person> = @@ -27,7 +30,14 @@ class PeopleFavorites val people = item.data.people ?.letNotEmpty { people -> - people.map { Person.fromDto(it, favorites[it.id] ?: false, api) } + people.map { + Person.fromDto( + context, + it, + favorites[it.id] ?: false, + api, + ) + } }.orEmpty() people } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 3d80aa9d..a299fe3c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -528,7 +528,7 @@ class SeriesViewModel api.userLibraryApi .getItem(item.id) .content.people - ?.map { Person.fromDto(it, api) } + ?.map { Person.fromDto(context, it, api) } .orEmpty() PeopleInItem(item.id, list) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt index b1500b60..0886590e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackOverlay.kt @@ -612,29 +612,28 @@ fun Controller( fontSize = subtitleTextSize, ) } - if (showClock) { - var endTimeStr by remember { mutableStateOf("...") } - LaunchedEffect(playerControls) { - while (isActive) { - val remaining = - (playerControls.duration - playerControls.currentPosition) - .div(playerControls.playbackParameters.speed) - .toLong() - .milliseconds - val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) - endTimeStr = TimeFormatter.format(endTime) - delay(1.seconds) - } + + var endTimeStr by remember { mutableStateOf("...") } + LaunchedEffect(playerControls) { + while (isActive) { + val remaining = + (playerControls.duration - playerControls.currentPosition) + .div(playerControls.playbackParameters.speed) + .toLong() + .milliseconds + val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) + endTimeStr = TimeFormatter.format(endTime) + delay(1.seconds) } - Text( - text = "Ends $endTimeStr", - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.labelLarge, - modifier = - Modifier - .padding(end = 32.dp), - ) } + Text( + text = "Ends $endTimeStr", + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, + modifier = + Modifier + .padding(end = 32.dp), + ) } } // TODO need to move these up a level? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 65afe072..967d6922 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -704,4 +704,17 @@ <item>@string/photos</item> </string-array> + <string name="actor">Actor</string> + <string name="composer">Composer</string> + <string name="writer">Writer</string> + <string name="guest_star">Guest star</string> + <string name="producer">Producer</string> + <string name="conductor">Conductor</string> + <string name="lyricist">Lyricist</string> + <string name="arranger">Arranger</string> + <string name="engineer">Engineer</string> + <string name="mixer">Mixer</string> + <string name="creator">Creator</string> + <string name="artist">Artist</string> + </resources> From 55466f803cdcb89fbdd9d7315b2bfe6c02957082 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 27 Feb 2026 01:00:44 -0500 Subject: [PATCH 166/176] Better animation to show/hide headers on grid pages (#1015) ## Description Changes the animation for showing/hiding the header title/buttons on grid pages. This eliminates the awkward jump the grid does to fill the screen. Instead it smoothly resizes. ### Related issues Fixes the second item in #221 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/ui/components/CollectionFolderGrid.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderLiveTv.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderMovie.kt | 10 ++++------ .../wholphin/ui/detail/CollectionFolderTv.kt | 10 ++++------ .../damontecres/wholphin/ui/detail/FavoritesPage.kt | 10 ++++------ .../wholphin/ui/detail/livetv/TvGuideGrid.kt | 8 +++++++- .../damontecres/wholphin/ui/discover/DiscoverPage.kt | 10 ++++------ 7 files changed, 31 insertions(+), 37 deletions(-) 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 4a71404a..1a33118d 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 @@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.components import android.content.Context import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.LocalBringIntoViewSpec @@ -802,8 +800,8 @@ fun CollectionFolderGridContent( ) { AnimatedVisibility( showHeader || loadingState !is DataLoadingState.Success, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt index dc426409..0d9bd338 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderLiveTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -127,8 +125,8 @@ fun CollectionFolderLiveTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt index 96ddbe94..6f67b011 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderMovie.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -76,8 +74,8 @@ fun CollectionFolderMovie( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index d72001ba..68b9dd18 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -80,8 +78,8 @@ fun CollectionFolderTv( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt index fc337d88..50b0053e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/FavoritesPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.detail import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -98,8 +96,8 @@ fun FavoritesPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt index 037a5ae7..30309304 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/livetv/TvGuideGrid.kt @@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv import android.text.format.DateUtils import android.widget.Toast import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Arrangement @@ -140,7 +142,11 @@ fun TvGuideGrid( .fillMaxHeight(.30f), ) } - AnimatedVisibility(focusedPosition.row < 1) { + AnimatedVisibility( + focusedPosition.row < 1, + enter = expandVertically(), + exit = shrinkVertically(), + ) { ExpandableFaButton( title = R.string.view_options, iconStringRes = R.string.fa_sliders, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt index 53b04161..5c18a0ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/DiscoverPage.kt @@ -1,10 +1,8 @@ package com.github.damontecres.wholphin.ui.discover import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -60,8 +58,8 @@ fun DiscoverPage( ) { AnimatedVisibility( showHeader, - enter = slideInVertically() + fadeIn(), - exit = slideOutVertically() + fadeOut(), + enter = expandVertically(), + exit = shrinkVertically(), ) { TabRow( selectedTabIndex = selectedTabIndex, From fd9063fbad8a2bf933058ba2b240de52c444324d Mon Sep 17 00:00:00 2001 From: voc0der <contact@netspace.in> Date: Sat, 28 Feb 2026 19:35:02 +0000 Subject: [PATCH 167/176] Hold-to-seek acceleration for playback DPAD and seekbar (#900) ## Description - Adds hold-to-seek acceleration for left/right DPAD input during playback skip handling. - Reuses one shared, duration-aware acceleration profile for both playback DPAD and seekbar skipping. - Extracts acceleration logic into a shared helper to keep playback and seekbar behavior consistent. - Slows the acceleration ramp (about one-third) to improve control during longer holds. - Preserves tap-on-release behavior: single taps seek on key release, while holds seek on repeated key-down without an extra release step. - Normalizes unknown/unset duration values to use the shortest-content acceleration profile. - Added unit coverage for shared seek acceleration math to prevent accidental regression in ramp thresholds. ### Related issues Supersedes #846 Incorporates #784 Closes #522 ### Testing Tested on Nvidia Shield Pro 2019. - Quick left/right tap still performs normal skip behavior. - Holding left/right seeks repeatedly with progressive acceleration. - Seekbar hold uses the same acceleration profile as playback hold. - Releasing after a hold does not trigger an extra seek step. ## AI or LLM usage Codex 5.3 and Claude Opus 4.6 were used for implementation, debugging, and cross-review. Changes were manually validated on device. --------- Co-authored-by: Ray <154766448+damontecres@users.noreply.github.com> --- .../ui/playback/PlaybackKeyHandler.kt | 96 +++++++++++++++- .../wholphin/ui/playback/PlaybackPage.kt | 1 + .../wholphin/ui/playback/SeekAcceleration.kt | 52 +++++++++ .../wholphin/ui/playback/SeekBar.kt | 104 ++++++++++++++---- .../ui/playback/SeekAccelerationTest.kt | 53 +++++++++ 5 files changed, 279 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt index f10a0758..5ed82729 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackKeyHandler.kt @@ -20,6 +20,7 @@ class PlaybackKeyHandler( private val skipWithLeftRight: Boolean, private val seekBack: Duration, private val seekForward: Duration, + private val getDurationMs: () -> Long, private val controllerViewState: ControllerViewState, private val updateSkipIndicator: (Long) -> Unit, private val skipBackOnResume: Duration?, @@ -28,15 +29,22 @@ class PlaybackKeyHandler( private val onStop: () -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, ) { + private var leftHandledByRepeat = false + private var rightHandledByRepeat = false + fun onKeyEvent(it: KeyEvent): Boolean { if (it.type == KeyEventType.KeyUp) onInteraction.invoke() - var result = true if (!controlsEnabled) { - result = false + return false + } else if (handleHoldSkip(it)) { + return true } else if (it.type != KeyEventType.KeyUp) { - result = false - } else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { + return false + } + + var result = true + if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { if (!controllerViewState.controlsVisible) { if (skipWithLeftRight && isSkipBack(it)) { updateSkipIndicator(-seekBack.inWholeMilliseconds) @@ -111,4 +119,84 @@ class PlaybackKeyHandler( } return result } + + private fun handleHoldSkip(event: KeyEvent): Boolean { + if ( + controllerViewState.controlsVisible || + !skipWithLeftRight || + (!isSkipBack(event) && !isSkipForward(event)) + ) { + return false + } + + val isBack = isSkipBack(event) + return when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + setHandledByRepeat(isBack = isBack, handled = false) + return true + } + val multiplier = + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = normalizedDurationMs(), + ) + setHandledByRepeat(isBack = isBack, handled = true) + seekWithMultiplier(isBack = isBack, multiplier = multiplier) + } else { + setHandledByRepeat(isBack = isBack, handled = false) + } + true + } + + KeyEventType.KeyUp -> { + if (!handledByRepeat(isBack = isBack)) { + seekWithMultiplier(isBack = isBack, multiplier = 1) + } + setHandledByRepeat(isBack = isBack, handled = false) + true + } + + else -> { + false + } + } + } + + private fun seekWithMultiplier( + isBack: Boolean, + multiplier: Int, + ) { + if (isBack) { + val skipDuration = seekBack * multiplier + player.seekBack(skipDuration) + updateSkipIndicator(-skipDuration.inWholeMilliseconds) + } else { + val skipDuration = seekForward * multiplier + player.seekForward(skipDuration) + updateSkipIndicator(skipDuration.inWholeMilliseconds) + } + } + + private fun setHandledByRepeat( + isBack: Boolean, + handled: Boolean, + ) { + if (isBack) { + leftHandledByRepeat = handled + } else { + rightHandledByRepeat = handled + } + } + + private fun handledByRepeat(isBack: Boolean): Boolean = + if (isBack) { + leftHandledByRepeat + } else { + rightHandledByRepeat + } + + private fun normalizedDurationMs(): Long = getDurationMs().coerceAtLeast(0L) } 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 1459736d..3b9958b6 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 @@ -235,6 +235,7 @@ fun PlaybackPageContent( skipWithLeftRight = true, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, + getDurationMs = { player.duration.coerceAtLeast(0L) }, controllerViewState = controllerViewState, updateSkipIndicator = updateSkipIndicator, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt new file mode 100644 index 00000000..1349a642 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -0,0 +1,52 @@ +package com.github.damontecres.wholphin.ui.playback + +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12 + +/** + * Shared seek acceleration profile for hold-to-seek behavior. + * Keep this in sync anywhere directional key repeat seeking is handled. + */ +fun calculateSeekAccelerationMultiplier( + repeatCount: Int, + durationMs: Long, +): Int { + if (repeatCount <= 0 || durationMs <= 0L) return 1 + + // Repeat cadence varies by device. Scaling down by 3 keeps ramp-up closer to multi-second holds. + val scaledRepeatCount = repeatCount / 3 + if (scaledRepeatCount <= 0) return 1 + + val durationMinutes = durationMs / 60_000L + return when { + durationMinutes < 30 -> { + if (scaledRepeatCount < 30) 1 else 2 + } + + durationMinutes < 90 -> { + when { + scaledRepeatCount < 13 -> 1 + scaledRepeatCount < 50 -> 2 + scaledRepeatCount < 75 -> 3 + else -> 4 + } + } + + durationMinutes < 150 -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 2 + scaledRepeatCount < 60 -> 4 + else -> 6 + } + } + + else -> { + when { + scaledRepeatCount < 20 -> 1 + scaledRepeatCount < 40 -> 3 + scaledRepeatCount < 60 -> 6 + else -> 10 + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index d1db4144..e34d91af 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme -import com.github.damontecres.wholphin.ui.handleDPadKeyEvents import kotlinx.coroutines.FlowPreview import kotlin.time.Duration @@ -80,15 +79,16 @@ fun SteppedSeekBarImpl( enabled = enabled, progress = progressToUse, bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse - offset).coerceAtLeast(0f) + seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f) hasSeeked = true seek(seekProgress) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() - seekProgress = (progressToUse + offset).coerceAtMost(1f) + seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f) hasSeeked = true seek(seekProgress) }, @@ -126,16 +126,19 @@ fun IntervalSeekBarImpl( enabled = enabled, progress = (progressToUse.toDouble() / durationMs).toFloat(), bufferedProgress = bufferedProgress, - onLeft = { + durationMs = durationMs, + onLeft = { multiplier -> controllerViewState.pulseControls() - seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) + seekPositionMs = + (progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L) hasSeeked = true onSeek(seekPositionMs) }, - onRight = { + onRight = { multiplier -> controllerViewState.pulseControls() seekPositionMs = - (progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) + (progressToUse + seekForward.inWholeMilliseconds * multiplier) + .coerceAtMost(durationMs) hasSeeked = true onSeek(seekPositionMs) }, @@ -148,8 +151,9 @@ fun IntervalSeekBarImpl( fun SeekBarDisplay( progress: Float, bufferedProgress: Float, - onLeft: () -> Unit, - onRight: () -> Unit, + durationMs: Long, + onLeft: (Int) -> Unit, + onRight: (Int) -> Unit, interactionSource: MutableInteractionSource, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -158,14 +162,13 @@ fun SeekBarDisplay( val onSurface = MaterialTheme.colorScheme.onSurface val isFocused by interactionSource.collectIsFocusedAsState() + var leftHandledByRepeat by remember { mutableStateOf(false) } + var rightHandledByRepeat by remember { mutableStateOf(false) } val animatedIndicatorHeight by animateDpAsState( targetValue = 6.dp.times((if (isFocused) 2f else 1f)), ) - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) { Canvas( modifier = Modifier @@ -173,24 +176,79 @@ fun SeekBarDisplay( .height(animatedIndicatorHeight) .padding(horizontal = 4.dp) .onPreviewKeyEvent { event -> - val trigger = - event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0 when (event.nativeKeyEvent.keyCode) { KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { - if (trigger) onLeft.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + leftHandledByRepeat = false + return@onPreviewKeyEvent true + } + leftHandledByRepeat = true + onLeft.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = durationMs, + ), + ) + } else { + leftHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!leftHandledByRepeat) { + onLeft.invoke(1) + } + leftHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { - if (trigger) onRight.invoke() + when (event.type) { + KeyEventType.KeyDown -> { + val repeatCount = event.nativeKeyEvent.repeatCount + if (repeatCount > 0) { + if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { + rightHandledByRepeat = false + return@onPreviewKeyEvent true + } + rightHandledByRepeat = true + onRight.invoke( + calculateSeekAccelerationMultiplier( + repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + durationMs = durationMs, + ), + ) + } else { + rightHandledByRepeat = false + } + } + + KeyEventType.KeyUp -> { + if (!rightHandledByRepeat) { + onRight.invoke(1) + } + rightHandledByRepeat = false + } + + else -> { + return@onPreviewKeyEvent false + } + } return@onPreviewKeyEvent true } } false - }.handleDPadKeyEvents( - onLeft = onLeft, - onRight = onRight, - ).focusable(enabled = enabled, interactionSource = interactionSource), + }.focusable(enabled = enabled, interactionSource = interactionSource), onDraw = { val yOffset = size.height.div(2) drawLine( diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt new file mode 100644 index 00000000..6a6037f9 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/SeekAccelerationTest.kt @@ -0,0 +1,53 @@ +package com.github.damontecres.wholphin.ui.playback + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeekAccelerationTest { + @Test + fun returnsOneWhenNotRepeating() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 0, durationMs = 30_000L)) + } + + @Test + fun unknownDurationDoesNotAccelerate() { + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = 0L)) + assertEquals(1, calculateSeekAccelerationMultiplier(repeatCount = 300, durationMs = -1L)) + } + + @Test + fun shortContentHasTwoTiers() { + val shortDurationMs = 20L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 89, durationMs = shortDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 90, durationMs = shortDurationMs), + ) + } + + @Test + fun mediumContentEscalatesAcrossAllTiers() { + val mediumDurationMs = 60L * 60_000L + + assertEquals( + 1, + calculateSeekAccelerationMultiplier(repeatCount = 38, durationMs = mediumDurationMs), + ) + assertEquals( + 2, + calculateSeekAccelerationMultiplier(repeatCount = 39, durationMs = mediumDurationMs), + ) + assertEquals( + 3, + calculateSeekAccelerationMultiplier(repeatCount = 150, durationMs = mediumDurationMs), + ) + assertEquals( + 4, + calculateSeekAccelerationMultiplier(repeatCount = 225, durationMs = mediumDurationMs), + ) + } +} From 0b73b4cf642a3ee9d5ff996827b6581fb887f02d Mon Sep 17 00:00:00 2001 From: Justin Caveda <justincaveda11031@gmail.com> Date: Sun, 1 Mar 2026 15:59:44 -0600 Subject: [PATCH 168/176] Fix: optimize suggestions performance (#849) ## Description - Aims to further optimize performance for the suggestions fetching ### Related issues Fixes #833 ### Testing Android Emulator NVIDIA Shield Pro 2019 ## Screenshots N/A ## AI or LLM usage AI being used to assist with updating/creating unit tests --- .../services/SuggestionsSchedulerService.kt | 35 ++++++++++++++++- .../wholphin/services/SuggestionsWorker.kt | 12 ++++-- .../SuggestionsSchedulerServiceTest.kt | 39 +++++++++++++++++-- .../services/SuggestionsWorkerTest.kt | 1 + 4 files changed, 79 insertions(+), 8 deletions(-) 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 index edca582b..86bab2f3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerService.kt @@ -8,6 +8,7 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.github.damontecres.wholphin.data.ServerRepository @@ -17,9 +18,11 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import java.util.UUID import javax.inject.Inject +import kotlin.random.Random import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -31,7 +34,6 @@ class SuggestionsSchedulerService constructor( @param:ActivityContext private val context: Context, private val serverRepository: ServerRepository, - private val cache: SuggestionsCache, private val workManager: WorkManager, ) { private val activity = @@ -42,6 +44,7 @@ class SuggestionsSchedulerService // Exposed for testing internal var dispatcher: CoroutineDispatcher = Dispatchers.IO + internal var initialDelaySecondsProvider: () -> Long = { 60L + Random.nextLong(0L, 121L) } init { serverRepository.current.observe(activity) { user -> @@ -60,6 +63,28 @@ class SuggestionsSchedulerService userId: UUID, serverId: UUID, ) { + val workInfos = + withContext(dispatcher) { + workManager + .getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) + .get() + } + val activeWork = + workInfos.firstOrNull { + !it.state.isFinished + } + val scheduledUserId = + activeWork + ?.tags + ?.firstOrNull { it.startsWith("user:") } + ?.removePrefix("user:") + + val isAlreadyScheduledForUser = scheduledUserId == userId.toString() + if (isAlreadyScheduledForUser) { + Timber.d("SuggestionsWorker already scheduled for user %s", userId) + return + } + val constraints = Constraints .Builder() @@ -75,12 +100,18 @@ class SuggestionsSchedulerService val periodicWorkRequestBuilder = PeriodicWorkRequestBuilder<SuggestionsWorker>( repeatInterval = 12.hours.toJavaDuration(), + flexTimeInterval = 1.hours.toJavaDuration(), ).setConstraints(constraints) .setBackoffCriteria( BackoffPolicy.EXPONENTIAL, 15.minutes.toJavaDuration(), ).setInputData(inputData) - .setInitialDelay(60.seconds.toJavaDuration()) + .addTag("user:$userId") + + val initialDelaySeconds = initialDelaySecondsProvider().coerceIn(60L, 180L) + periodicWorkRequestBuilder.setInitialDelay(initialDelaySeconds.seconds.toJavaDuration()) + + Timber.i("Scheduling periodic SuggestionsWorker with ${initialDelaySeconds}s delay") workManager.enqueueUniquePeriodicWork( uniqueWorkName = SuggestionsWorker.WORK_NAME, 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 index eab87f20..4560f2d9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SuggestionsWorker.kt @@ -99,11 +99,17 @@ class SuggestionsWorker itemsPerRow, ) ensureActive() + val newIds = suggestions.map { it.id } + val cachedIds = cache.get(userId, view.id, itemKind)?.ids + if (cachedIds == newIds) { + Timber.v("Suggestions unchanged for view %s, skipping cache write", view.id) + return@runCatching + } cache.put( userId, view.id, itemKind, - suggestions.map { it.id }, + newIds, ) }.onFailure { e -> Timber.e( @@ -242,7 +248,7 @@ class SuggestionsWorker GetItemsRequest( parentId = parentId, userId = userId, - fields = listOf(ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, ItemFields.OVERVIEW) + extraFields, + fields = extraFields, includeItemTypes = listOf(itemKind), genreIds = genreIds, recursive = true, @@ -252,7 +258,7 @@ class SuggestionsWorker sortOrder = sortOrder?.let { listOf(it) }, limit = limit, enableTotalRecordCount = false, - imageTypeLimit = 1, + imageTypeLimit = 0, ) return GetItemsRequestHandler .execute(api, request) 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 index 7da1d63c..0b48a011 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsSchedulerServiceTest.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.CurrentUser import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.google.common.util.concurrent.ListenableFuture import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -37,7 +38,6 @@ class SuggestionsSchedulerServiceTest { private val currentLiveData = MutableLiveData<CurrentUser?>() private val mockActivity = mockk<AppCompatActivity>(relaxed = true) private val mockServerRepository = mockk<ServerRepository>(relaxed = true) - private val mockCache = mockk<SuggestionsCache>(relaxed = true) private val mockWorkManager = mockk<WorkManager>(relaxed = true) private val lifecycleRegistry = LifecycleRegistry(mockk<LifecycleOwner>(relaxed = true)) @@ -56,13 +56,23 @@ class SuggestionsSchedulerServiceTest { SuggestionsSchedulerService( context = mockActivity, serverRepository = mockServerRepository, - cache = mockCache, workManager = mockWorkManager, - ).also { it.dispatcher = testDispatcher } + ).also { + it.dispatcher = testDispatcher + it.initialDelaySecondsProvider = { 60L } + } + + private fun mockWorkInfos(infos: List<androidx.work.WorkInfo>) { + @Suppress("UNCHECKED_CAST") + val future = mockk<ListenableFuture<List<androidx.work.WorkInfo>>>() + every { future.get() } returns infos + every { mockWorkManager.getWorkInfosForUniqueWork(SuggestionsWorker.WORK_NAME) } returns future + } @Test fun schedules_periodic_work_when_user_present() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -76,6 +86,7 @@ class SuggestionsSchedulerServiceTest { @Test fun cancels_work_when_user_null() = runTest { + mockWorkInfos(emptyList()) createService() currentLiveData.value = CurrentUser( @@ -91,6 +102,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -115,6 +127,7 @@ class SuggestionsSchedulerServiceTest { @Test fun schedules_periodic_work_with_delay_when_cache_not_empty() = runTest { + mockWorkInfos(emptyList()) val workRequestSlot = slot<PeriodicWorkRequest>() every { mockWorkManager.enqueueUniquePeriodicWork( @@ -135,4 +148,24 @@ class SuggestionsSchedulerServiceTest { verify { mockWorkManager.enqueueUniquePeriodicWork(SuggestionsWorker.WORK_NAME, any(), any()) } assertEquals(60000L, workRequestSlot.captured.workSpec.initialDelay) } + + @Test + fun does_not_schedule_if_already_scheduled_for_same_user() = + runTest { + val userId = UUID.randomUUID() + val workInfo = mockk<androidx.work.WorkInfo>() + every { workInfo.state } returns androidx.work.WorkInfo.State.ENQUEUED + every { workInfo.tags } returns setOf("user:$userId") + mockWorkInfos(listOf(workInfo)) + + createService() + currentLiveData.value = + CurrentUser( + user = JellyfinUser(id = userId, name = "User", serverId = UUID.randomUUID(), accessToken = "token"), + server = JellyfinServer(id = UUID.randomUUID(), name = "Server", url = "http://localhost", version = null), + ) + advanceUntilIdle() + + verify(exactly = 0) { mockWorkManager.enqueueUniquePeriodicWork(any(), any(), any()) } + } } 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 index 7d633d87..5679777e 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SuggestionsWorkerTest.kt @@ -46,6 +46,7 @@ class SuggestionsWorkerTest { every { mockApi.userViewsApi } returns mockUserViewsApi every { mockApi.baseUrl } returns "http://localhost" every { mockApi.accessToken } returns "test-token" + coEvery { mockCache.get(any(), any(), any()) } returns null } @After From 8935067c5a966abdc4085bf2de0d17b1433774d8 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:38:41 -0500 Subject: [PATCH 169/176] Add option to delete media (#1014) ## Description Adds a setting to enable "Media management" which adds a context menu item to delete media. This is accessible using either the More button or long clicking. You can delete episodes, seasons, series and movies/videos. If order for the option to appear the current user must have server-side settings for "Allow media deletion from" enabled for the library and the in-app setting under Settings->Advanced Settings->"Show media management options". ### Related issues Closes #104 ### Testing Emulator ## Screenshots ### Context menu ![delete_context_2 Large](https://github.com/user-attachments/assets/cabc3c24-1b43-4f88-8d71-b7275f9eadc4) ### Confirmation ![delete_confirm Large](https://github.com/user-attachments/assets/8036dde1-8a51-424b-bbdd-f2826dd3e0ab) ## AI or LLM usage None --- .../wholphin/WholphinApplication.kt | 14 +- .../wholphin/data/model/BaseItem.kt | 2 + .../wholphin/preferences/AppPreference.kt | 13 ++ .../services/MediaManagementService.kt | 104 +++++++++++++ .../wholphin/services/NavDrawerService.kt | 6 +- .../wholphin/services/hilt/AppModule.kt | 2 +- .../damontecres/wholphin/ui/UiConstants.kt | 2 + .../ui/components/CollectionFolderGrid.kt | 16 ++ .../wholphin/ui/components/Dialogs.kt | 144 ++++++++++++++++-- .../wholphin/ui/components/PlayButtons.kt | 63 +++++++- .../wholphin/ui/detail/DetailUtils.kt | 25 +++ .../wholphin/ui/detail/movie/MovieDetails.kt | 15 ++ .../ui/detail/movie/MovieViewModel.kt | 13 ++ .../ui/detail/series/SeriesDetails.kt | 100 +++++++++--- .../ui/detail/series/SeriesOverview.kt | 47 ++++-- .../ui/detail/series/SeriesViewModel.kt | 65 +++++++- .../wholphin/util/ApiRequestPager.kt | 16 ++ app/src/main/proto/WholphinDataStore.proto | 1 + app/src/main/res/values/strings.xml | 2 + 19 files changed, 597 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt index 900fc4c3..ceb7264e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinApplication.kt @@ -62,13 +62,13 @@ class WholphinApplication : .penaltyDeathOnNetwork() .build(), ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy - .Builder() - .detectAll() - .penaltyLog() - .build(), - ) +// StrictMode.setVmPolicy( +// StrictMode.VmPolicy +// .Builder() +// .detectAll() +// .penaltyLog() +// .build(), +// ) } OkHttp.initialize(this) initAcra { 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 e88673c1..3c34851b 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 @@ -72,6 +72,8 @@ data class BaseItem( } } + val canDelete: Boolean get() = data.canDelete == true + @Transient val aspectRatio: Float? = data.primaryImageAspectRatio?.toFloat()?.takeIf { it > 0 } 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 379b782b..ef3f14e7 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 @@ -741,6 +741,18 @@ sealed interface AppPreference<Pref, T> { valueToIndex = { it.number }, ) + val ManageMedia = + AppSwitchPreference<AppPreferences>( + title = R.string.show_media_management, + defaultValue = false, + getter = { it.interfacePreferences.enableMediaManagement }, + setter = { prefs, value -> + prefs.updateInterfacePreferences { enableMediaManagement = value } + }, + summaryOn = R.string.enabled, + summaryOff = R.string.disabled, + ) + val OneClickPause = AppSwitchPreference<AppPreferences>( title = R.string.one_click_pause, @@ -1110,6 +1122,7 @@ val advancedPreferences = preferences = listOf( AppPreference.ShowClock, + AppPreference.ManageMedia, AppPreference.CombineContinueNext, // Temporarily disabled, see https://github.com/damontecres/Wholphin/pull/127#issuecomment-3478058418 // AppPreference.NavDrawerSwitchOnFocus, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt new file mode 100644 index 00000000..f3aff8c6 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -0,0 +1,104 @@ +package com.github.damontecres.wholphin.services + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.showToast +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class MediaManagementService + @Inject + constructor( + @param:ApplicationContext private val context: Context, + private val api: ApiClient, + private val serverRepository: ServerRepository, + private val userPreferencesService: UserPreferencesService, + ) { + private val _deletedItemFlow = + MutableSharedFlow<DeletedItem>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + + /** + * Listen for recently deleted items. Useful for ViewModels to react and refresh data + */ + val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow + + suspend fun canDelete(item: BaseItem): Boolean { + Timber.v("canDelete %s: %s", item.id, item.canDelete) + val enabled = + userPreferencesService + .getCurrent() + .appPreferences.interfacePreferences.enableMediaManagement + return enabled && + item.canDelete && + if (item.type == BaseItemKind.RECORDING) { + serverRepository.currentUserDto.value + ?.policy + ?.enableLiveTvManagement == true + } else { + true + } + } + + suspend fun deleteItem(item: BaseItem): DeleteResult { + try { + Timber.i("Deleting %s", item.id) + // TODO enable + api.libraryApi.deleteItem(item.id) + _deletedItemFlow.emit(DeletedItem(item)) + return DeleteResult.Success + } catch (ex: Exception) { + Timber.e(ex, "Error deleting %s", item.id) + return DeleteResult.Error(ex) + } + } + } + +data class DeletedItem( + val item: BaseItem, +) + +sealed interface DeleteResult { + data object Success : DeleteResult + + data class Error( + val ex: Exception, + ) : DeleteResult +} + +fun ViewModel.deleteItem( + context: Context, + mediaManagementService: MediaManagementService, + item: BaseItem, + onSuccess: () -> Unit = {}, +) = viewModelScope.launchIO { + when (val r = mediaManagementService.deleteItem(item)) { + is DeleteResult.Error -> { + showToast( + context, + "Error deleting item: ${r.ex.localizedMessage}", + ) + } + + DeleteResult.Success -> { + showToast(context, "Deleted") + onSuccess.invoke() + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt index 6b6111e4..b83f0bd0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt @@ -15,6 +15,7 @@ import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.supportedCollectionTypes import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.catch @@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.userViewsApi @@ -149,7 +151,9 @@ class NavDrawerService val allItems = builtins + libraries val navDrawerPins = - serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + withContext(Dispatchers.IO) { + serverPreferencesDao.getNavDrawerPinnedItems(user).associateBy { it.itemId } + } val items = mutableListOf<NavDrawerItem>() val moreItems = mutableListOf<NavDrawerItem>() 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 546636e5..e3e42efa 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 @@ -185,7 +185,7 @@ object AppModule { @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @Provides @Singleton 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 2d9b6d5e..ab0f69f8 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 @@ -58,6 +58,7 @@ val DefaultItemFields = ItemFields.MEDIA_SOURCES, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) /** @@ -72,6 +73,7 @@ val SlimItemFields = ItemFields.SORT_NAME, ItemFields.MEDIA_SOURCE_COUNT, ItemFields.PARENT_ID, + ItemFields.CAN_DELETE, ) val PhotoItemFields = 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 1a33118d..6eae4f26 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 @@ -60,6 +60,7 @@ import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer @@ -76,6 +77,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader import com.github.damontecres.wholphin.ui.nav.Destination @@ -128,6 +130,7 @@ class CollectionFolderViewModel private val navigationManager: NavigationManager, private val themeSongPlayer: ThemeSongPlayer, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, val mediaReportService: MediaReportService, @Assisted itemId: String, @Assisted initialSortAndDirection: SortAndDirection?, @@ -199,6 +202,19 @@ class CollectionFolderViewModel loading.setValueOnMain(DataLoadingState.Error(ex)) } } + viewModelScope.launchDefault { + mediaManagementService.deletedItemFlow.collect { deletedItem -> + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id) + val item = pager?.get(it) + if (item?.id == deletedItem.item.id) { + pager.refreshPagesAfter(position) + } + } + } + } } private fun saveLibraryDisplayInfo( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index 579d9a97..a1f2362a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -6,6 +6,8 @@ import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -17,11 +19,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -30,9 +35,12 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector @@ -56,8 +64,12 @@ import androidx.tv.material3.surfaceColorAtElevation import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.TrackIndex import com.github.damontecres.wholphin.ui.FontAwesome +import com.github.damontecres.wholphin.ui.ifElse import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.playback.SimpleMediaStream +import com.github.damontecres.wholphin.ui.playback.isDown +import com.github.damontecres.wholphin.ui.playback.isUp +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -259,31 +271,65 @@ fun DialogPopupContent( style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() + val focusRequesters = remember { List(dialogItems.size) { FocusRequester() } } LazyColumn( + state = listState, modifier = Modifier, ) { - items(dialogItems) { - when (it) { + itemsIndexed(dialogItems) { index, item -> + when (item) { is DialogItemDivider -> { HorizontalDivider(Modifier.height(16.dp)) } is DialogItem -> { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() ListItem( - selected = it.selected, - enabled = !waiting && it.enabled, + selected = item.selected, + enabled = !waiting && item.enabled, onClick = { if (dismissOnClick) { onDismissRequest.invoke() } - it.onClick.invoke() + item.onClick.invoke() }, - headlineContent = it.headlineContent, - overlineContent = it.overlineContent, - supportingContent = it.supportingContent, - leadingContent = it.leadingContent, - trailingContent = it.trailingContent, - modifier = Modifier, + headlineContent = item.headlineContent, + overlineContent = item.overlineContent, + supportingContent = item.supportingContent, + leadingContent = item.leadingContent, + trailingContent = item.trailingContent, + interactionSource = interactionSource, + modifier = + Modifier + .focusRequester(focusRequesters[index]) + .ifElse( + index == 0, + Modifier.onKeyEvent { + if (focused && isUp(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(dialogItems.lastIndex) + focusRequesters[dialogItems.lastIndex].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ).ifElse( + index == dialogItems.lastIndex, + Modifier.onKeyEvent { + if (focused && isDown(it) && it.type == KeyEventType.KeyDown) { + scope.launch { + listState.animateScrollToItem(0) + focusRequesters[0].tryRequestFocus() + } + return@onKeyEvent true + } + false + }, + ), ) } } @@ -471,6 +517,80 @@ fun ConfirmDialogContent( } } +@Composable +fun ConfirmDeleteDialog( + itemTitle: String, + onCancel: () -> Unit, + onConfirm: () -> Unit, +) { + BasicDialog( + onDismissRequest = onCancel, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(16.dp), + modifier = Modifier.wrapContentWidth(), + ) { + item { + Text( + text = stringResource(R.string.delete_item), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + item { + Text( + text = itemTitle, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = + Modifier + .padding(bottom = 8.dp), + ) + } + + item { + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier, + ) { + Button( + onClick = onCancel, + modifier = Modifier.width(72.dp), + ) { + Text( + text = stringResource(R.string.cancel), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + Button( + onClick = onConfirm, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(horizontal = 4.dp), + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = Color.Red.copy(alpha = .8f), + modifier = Modifier, + ) + Text( + text = stringResource(R.string.confirm), + ) + } + } + } + } + } + } +} + fun chooseVersionParams( context: Context, sources: List<MediaSourceInfo>, 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 17b84dee..570d411e 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 @@ -2,11 +2,15 @@ package com.github.damontecres.wholphin.ui.components import androidx.annotation.StringRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.focusGroup import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -17,6 +21,7 @@ import androidx.compose.foundation.layout.requiredSizeIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh @@ -34,6 +39,7 @@ import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource @@ -42,6 +48,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Icon +import androidx.tv.material3.LocalContentColor import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R @@ -234,7 +241,7 @@ fun ExpandablePlayButton( fun ExpandablePlayButton( @StringRes title: Int, resume: Duration, - icon: @Composable () -> Unit, + icon: @Composable BoxScope.() -> Unit, onClick: (position: Duration) -> Unit, modifier: Modifier = Modifier, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, @@ -254,12 +261,13 @@ fun ExpandablePlayButton( interactionSource = interactionSource, ) { Box( + contentAlignment = Alignment.Center, modifier = Modifier - .padding(start = 2.dp, top = 2.dp) + .padding(start = 2.dp) .height(MinButtonSize), ) { - icon.invoke() + icon.invoke(this) } AnimatedVisibility(isFocused) { Spacer(Modifier.size(8.dp)) @@ -359,6 +367,48 @@ fun TrailerButton( } } +@Composable +fun DeleteButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focused by interactionSource.collectIsFocusedAsState() + val iconTint by + animateColorAsState( + targetValue = + if (focused) { + Color.Red.copy(alpha = .8f) + } else { + MaterialTheme.colorScheme.onSurface.copy( + alpha = 0.8f, + ) + }, + animationSpec = + tween( + easing = LinearEasing, + ), + ) + ExpandablePlayButton( + title = R.string.delete, + resume = Duration.ZERO, + icon = { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = if (iconTint.isSpecified) iconTint else LocalContentColor.current, + modifier = + Modifier + .padding(start = 2.dp) + .size(24.dp), + ) + }, + onClick = { onClick.invoke() }, + interactionSource = interactionSource, + modifier = modifier, + ) +} + @PreviewTvSpec @Composable private fun ExpandablePlayButtonsPreview() { @@ -417,12 +467,19 @@ private fun ViewOptionsPreview() { onClick = {}, modifier = Modifier, ) + DeleteButton( + onClick = {}, + ) SortByButton( sortOptions = listOf(), current = SortAndDirection(ItemSortBy.DEFAULT, SortOrder.ASCENDING), onSortChange = {}, ) } + DeleteButton( + onClick = {}, + interactionSource = source, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index cebfff70..645ba2ce 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -29,6 +29,7 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, + val onClickDelete: (BaseItem) -> Unit = {}, ) enum class ClearChosenStreams { @@ -62,6 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -139,6 +141,17 @@ fun buildMoreDialogItems( actions.onClickAddPlaylist.invoke(item.id) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, @@ -223,6 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean = false, actions: MoreDialogActions, ): List<DialogItem> = buildList { @@ -290,6 +304,17 @@ fun buildMoreDialogItemsForHome( actions.onClickAddPlaylist.invoke(itemId) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + actions.onClickDelete.invoke(item) + }, + ) + } add( DialogItem( text = if (watched) R.string.mark_unwatched else R.string.mark_watched, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 2a40a399..ada15158 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -111,6 +112,7 @@ fun MovieDetails( var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val moreActions = MoreDialogActions( @@ -126,6 +128,7 @@ fun MovieDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -201,6 +204,7 @@ fun MovieDetails( seriesId = null, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete, actions = moreActions, onChooseVersion = { chooseVersion = @@ -291,6 +295,7 @@ fun MovieDetails( playbackPosition = similar.playbackPosition, watched = similar.played, favorite = similar.favorite, + canDelete = false, actions = moreActions, ) moreDialog = @@ -362,6 +367,16 @@ fun MovieDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index ed46656c..993260dc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -26,6 +27,7 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty @@ -73,6 +75,7 @@ class MovieViewModel private val extrasService: ExtrasService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -90,6 +93,9 @@ class MovieViewModel val chosenStreams = MutableLiveData<ChosenStreams?>(null) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) + var canDelete: Boolean = false + private set + init { init() } @@ -106,6 +112,7 @@ class MovieViewModel api.userLibraryApi.getItem(itemId).content.let { BaseItem.from(it, api) } + canDelete = mediaManagementService.canDelete(item) this@MovieViewModel.item.setValueOnMain(item) item } @@ -274,4 +281,10 @@ class MovieViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 579a3866..222f7f77 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -24,12 +25,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -53,7 +56,9 @@ import com.github.damontecres.wholphin.ui.cards.ExtrasRow import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDialog +import com.github.damontecres.wholphin.ui.components.DeleteButton import com.github.damontecres.wholphin.ui.components.DialogItem import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup @@ -76,6 +81,7 @@ import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson import com.github.damontecres.wholphin.ui.discover.DiscoverRow import com.github.damontecres.wholphin.ui.discover.DiscoverRowData +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt @@ -102,21 +108,25 @@ fun SeriesDetails( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() + val focusManager = LocalFocusManager.current val loading by viewModel.loading.observeAsState(LoadingState.Loading) val item by viewModel.item.observeAsState() + val canDelete by viewModel.canDeleteSeries.collectAsState() val seasons by viewModel.seasons.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) val discovered by viewModel.discovered.collectAsState() + val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var showWatchConfirmation by remember { mutableStateOf(false) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } - val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } when (val state = loading) { is LoadingState.Error -> { @@ -150,6 +160,7 @@ fun SeriesDetails( similar = similar, played = played, favorite = item.data.userData?.isFavorite ?: false, + canDelete = canDelete, modifier = modifier, onClickItem = { index, item -> viewModel.navigateTo(item.destination()) @@ -163,23 +174,29 @@ fun SeriesDetails( ) }, onLongClickItem = { index, season -> - seasonDialog = - buildDialogForSeason( - context = context, - s = season, - onClickItem = { viewModel.navigateTo(it.destination()) }, - markPlayed = { played -> - viewModel.setSeasonWatched(season.id, played) - }, - onClickPlay = { shuffle -> - viewModel.navigateTo( - Destination.PlaybackList( - itemId = season.id, - shuffle = shuffle, - ), - ) - }, - ) + scope.launchDefault { + seasonDialog = + buildDialogForSeason( + context = context, + s = season, + canDelete = viewModel.canDelete(season), + onClickItem = { viewModel.navigateTo(it.destination()) }, + markPlayed = { played -> + viewModel.setSeasonWatched(season.id, played) + }, + onClickPlay = { shuffle -> + viewModel.navigateTo( + Destination.PlaybackList( + itemId = season.id, + shuffle = shuffle, + ), + ) + }, + onClickDelete = { + showDeleteDialog = it + }, + ) + } }, overviewOnClick = { overviewDialog = @@ -231,6 +248,9 @@ fun SeriesDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), ) if (showWatchConfirmation) { @@ -283,6 +303,19 @@ fun SeriesDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.title ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + if (seasons?.lastOrNull()?.id == item.id) { + focusManager.moveFocus(FocusDirection.Previous) + } + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -305,6 +338,7 @@ fun SeriesDetailsContent( discovered: List<DiscoverItem>, played: Boolean, favorite: Boolean, + canDelete: Boolean, onClickItem: (Int, BaseItem) -> Unit, onClickPerson: (Person) -> Unit, onLongClickItem: (Int, BaseItem) -> Unit, @@ -436,6 +470,23 @@ fun SeriesDetailsContent( } }, ) + if (canDelete) { + DeleteButton( + onClick = { + position = HEADER_ROW + moreActions.onClickDelete.invoke(series) + }, + modifier = + Modifier + .onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } } } item { @@ -638,9 +689,11 @@ fun SeriesDetailsHeader( fun buildDialogForSeason( context: Context, s: BaseItem, + canDelete: Boolean, onClickItem: (BaseItem) -> Unit, markPlayed: (Boolean) -> Unit, onClickPlay: (Boolean) -> Unit, + onClickDelete: (BaseItem) -> Unit, ): DialogParams { val items = buildList { @@ -679,6 +732,17 @@ fun buildDialogForSeason( onClickPlay.invoke(true) }, ) + if (canDelete) { + add( + DialogItem( + context.getString(R.string.delete), + Icons.Default.Delete, + iconColor = Color.Red.copy(alpha = .8f), + ) { + onClickDelete.invoke(s) + }, + ) + } } return DialogParams( title = s.name ?: context.getString(R.string.tv_season), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 8fba8ed6..79731e47 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -23,6 +24,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -36,9 +38,11 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.seasonEpisode +import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.flow.update import kotlinx.serialization.Serializable @@ -89,6 +93,7 @@ fun SeriesOverview( playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current + val scope = rememberCoroutineScope() val firstItemFocusRequester = remember { FocusRequester() } val episodeRowFocusRequester = remember { FocusRequester() } val castCrewRowFocusRequester = remember { FocusRequester() } @@ -118,6 +123,7 @@ fun SeriesOverview( val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var rowFocused by rememberInt() + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } LaunchedEffect(episodes) { episodes?.let { episodes -> @@ -177,7 +183,7 @@ fun SeriesOverview( } } - fun buildMoreForEpisode( + suspend fun buildMoreForEpisode( ep: BaseItem, chosenStreams: ChosenStreams?, fromLongClick: Boolean, @@ -194,6 +200,7 @@ fun SeriesOverview( seriesId = series.id, sourceId = chosenStreams?.source?.id?.toUUIDOrNull(), canClearChosenStreams = chosenStreams?.itemPlayback != null || chosenStreams?.plc != null, + canDelete = viewModel.canDelete(ep), actions = MoreDialogActions( navigateTo = viewModel::navigateTo, @@ -216,6 +223,9 @@ fun SeriesOverview( showPlaylistDialog = it }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = it + }, ), onChooseVersion = { chooseVersion = @@ -315,7 +325,9 @@ fun SeriesOverview( ) }, onLongClick = { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, true) + } }, playOnClick = { resume -> rowFocused = EPISODE_ROW @@ -343,18 +355,22 @@ fun SeriesOverview( }, moreOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { ep -> - moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + scope.launchDefault { + moreDialog = buildMoreForEpisode(ep, chosenStreams, false) + } } }, overviewOnClick = { episodeList?.getOrNull(position.episodeRowIndex)?.let { - overviewDialog = - ItemDetailsDialogInfo( - title = it.name ?: context.getString(R.string.unknown), - overview = it.data.overview, - genres = it.data.genres.orEmpty(), - files = it.data.mediaSources.orEmpty(), - ) + scope.launchDefault { + overviewDialog = + ItemDetailsDialogInfo( + title = it.name ?: context.getString(R.string.unknown), + overview = it.data.overview, + genres = it.data.genres.orEmpty(), + files = it.data.mediaSources.orEmpty(), + ) + } } }, personOnClick = { @@ -420,6 +436,17 @@ fun SeriesOverview( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = item.subtitle ?: "", + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + episodeRowFocusRequester.tryRequestFocus() + showDeleteDialog = null + }, + ) + } } private const val EPISODE_ROW = 0 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index a299fe3c..0e1b326b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -16,6 +16,7 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites @@ -24,10 +25,12 @@ import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.detail.ItemViewModel import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.gt +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.lt @@ -90,6 +93,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -111,6 +115,7 @@ class SeriesViewModel val extras = MutableLiveData<List<ExtrasItem>>(listOf()) val people = MutableLiveData<List<Person>>(listOf()) val similar = MutableLiveData<List<BaseItem>>() + val canDeleteSeries = MutableStateFlow(false) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) @@ -127,6 +132,7 @@ class SeriesViewModel Timber.v("Start") addCloseable { themeSongPlayer.stop() } val item = fetchItem(seriesId) + canDeleteSeries.update { mediaManagementService.canDelete(item) } backdropService.submit(item) val seasonsDeferred = getSeasons(item, seasonEpisodeIds?.seasonNumber) @@ -259,9 +265,12 @@ class SeriesViewModel if (seriesPageType == SeriesPageType.DETAILS) { listOf( ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ) } else { - null + listOf( + ItemFields.CAN_DELETE, + ) }, ) val pager = @@ -300,6 +309,7 @@ class SeriesViewModel ItemFields.OVERVIEW, ItemFields.CUSTOM_RATING, ItemFields.PRIMARY_IMAGE_ASPECT_RATIO, + ItemFields.CAN_DELETE, ), ) Timber.v( @@ -551,6 +561,59 @@ class SeriesViewModel lookUpChosenTracks(item.id, item) } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + if (item.type == BaseItemKind.SERIES) { + navigationManager.goBack() + } else if (seriesPageType == SeriesPageType.DETAILS) { + this@SeriesViewModel.item.value?.let { series -> + val seasons = getSeasons(series, null).await() + if (seasons.isEmpty()) { + navigationManager.goBack() + } else { + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + } + } else { + position.value.let { (_, episodeIndex) -> + val eps = episodes.value as? EpisodeList.Success + if (eps != null) { + val pager = eps.episodes + val lastIndex = pager.lastIndex + pager.refreshPagesAfter(episodeIndex) + if (pager.isEmpty()) { + navigationManager.goBack() + } else { + if (episodeIndex == lastIndex) { + // Deleted last episode, so need to move left + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex - 1, + ), + ) + position.update { it.copy(episodeRowIndex = episodeIndex - 1) } + } else { + episodes.setValueOnMain( + EpisodeList.Success( + eps.seasonId, + pager, + episodeIndex, + ), + ) + } + } + } + } + } + } + } + } + + suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index b03ce9f9..4b30c418 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -166,6 +166,22 @@ class ApiRequestPager<T>( } } + /** + * Dumps the cache for all the pages at or after the given position and fetches a new page + */ + suspend fun refreshPagesAfter(position: Int) { + val pageNumber = position / pageSize + cachedPages.asMap().apply { + keys.forEach { pageKey -> + if (pageKey >= pageNumber) { + if (DEBUG) Timber.v("refreshPagesAfter: dropping %s", pageKey) + remove(pageKey) + } + } + } + fetchPageBlocking(position, true) + } + companion object { private const val DEBUG = false } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 8d6034b1..081b25e4 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -166,6 +166,7 @@ message InterfacePreferences { BackdropStyle backdrop_style = 9; SubtitlePreferences hdr_subtitles_preferences = 10; ScreensaverPreferences screensaver_preference = 11; + bool enable_media_management = 12; } message AdvancedPreferences { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 967d6922..cc1ebf9d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -692,6 +692,8 @@ <string name="no_subtitles_found">No remote subtitles were found</string> <string name="series_continueing">Present</string> <string name="in_app_screensaver">Use in-app screensaver</string> + <string name="delete_item">Are you sure you want to delete this item?</string> + <string name="show_media_management">Show media management options</string> <string-array name="backdrop_style_options"> <item>@string/backdrop_style_dynamic</item> <item>@string/backdrop_style_image</item> From 00047012962cecc5a4836ccd45c7ef1ab83b3aa2 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:46:46 -0500 Subject: [PATCH 170/176] Dev: add simple automated UI test setup (#1027) ## Description This PR is mostly a proof-of-concept for setting up automated UI testing. The tests use Roboelectric so they can run without an emulator. There is also a sample instrumented test for running on emulators or devices for future tests. It adds two basic automated UI tests for adding a server. These are sort integration tests because they use the actual implementations for the view model and services like `ServerRepository` and `JellyfinServerDao`. Also, moves the bulk of `MainActivity`'s compose code into a function for future tests. ### Related issues N/A ### Testing Local testing ## Screenshots N/A ## AI or LLM usage None --- app/build.gradle.kts | 14 +- .../wholphin/test/InstrumentedBasicUiTests.kt | 75 +++++ .../damontecres/wholphin/test/TestActivity.kt | 7 + .../wholphin/test/WholphinTestRunner.kt | 14 + app/src/debug/AndroidManifest.xml | 94 ++++++ .../damontecres/wholphin/MainActivity.kt | 171 ++--------- .../damontecres/wholphin/MainContent.kt | 149 +++++++++ .../wholphin/data/ServerRepository.kt | 21 +- .../services/SetupNavigationManager.kt | 2 +- .../wholphin/services/hilt/AppModule.kt | 43 ++- .../wholphin/ui/setup/ServerList.kt | 6 +- .../wholphin/ui/setup/SwitchServerContent.kt | 2 + .../ui/setup/SwitchServerViewModel.kt | 5 + .../wholphin/util/CrashReportSenderFactory.kt | 3 +- .../damontecres/wholphin/test/TestActivity.kt | 7 + .../damontecres/wholphin/ui/BasicUiTests.kt | 238 +++++++++++++++ .../damontecres/wholphin/ui/TestModule.kt | 282 ++++++++++++++++++ .../github/damontecres/wholphin/ui/Utils.kt | 16 + gradle/libs.versions.toml | 4 + 19 files changed, 990 insertions(+), 163 deletions(-) create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt create mode 100644 app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/main/java/com/github/damontecres/wholphin/MainContent.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 366dfcd1..22bd0deb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -44,7 +44,7 @@ android { targetSdk = 36 versionCode = gitTags.trim().lines().size versionName = gitDescribe.trim().removePrefix("v").ifBlank { "0.0.0" } - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunner = "com.github.damontecres.wholphin.test.WholphinTestRunner" } signingConfigs { @@ -140,6 +140,12 @@ android { kotlin.directories += "$buildDir/generated/seerr_api/src/main/kotlin" } } + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } protobuf { @@ -254,6 +260,7 @@ dependencies { implementation(libs.androidx.room.testing) implementation(libs.androidx.palette.ktx) implementation(libs.androidx.media3.effect) + implementation(libs.androidx.runner) ksp(libs.androidx.room.compiler) ksp(libs.hilt.android.compiler) ksp(libs.androidx.hilt.compiler) @@ -292,4 +299,9 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.androidx.core.testing) testImplementation(libs.robolectric) + testImplementation(libs.hilt.android.testing) + testImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.mockk.android) + androidTestImplementation(libs.hilt.android.testing) + androidTestImplementation(libs.androidx.ui.test.manifest) } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt new file mode 100644 index 00000000..72cf2896 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/InstrumentedBasicUiTests.kt @@ -0,0 +1,75 @@ +package com.github.damontecres.wholphin.test + +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.MainContent +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@HiltAndroidTest +class InstrumentedBasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + lateinit var screensaverService: ScreensaverService + + @Before + fun setup() { + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns MutableStateFlow(ScreensaverState(false, false, false, false)) + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun myTest() { + // Start the app + composeTestRule.setContent { + WholphinTheme { + MainContent( + backStack = mutableListOf(SetupDestination.ServerList), + navigationManager = mockk(relaxed = true), + appPreferences = mockk(relaxed = true), + backdropService = mockk(relaxed = true), + screensaverService = screensaverService, + requestedDestination = Destination.Home(), + modifier = Modifier, + ) + } + } + + composeTestRule.onNodeWithText("Add Server").assertExists() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO + } + composeTestRule.onNodeWithTag("add_server").performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertExists() + } +} + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt new file mode 100644 index 00000000..694186ec --- /dev/null +++ b/app/src/androidTest/java/com/github/damontecres/wholphin/test/WholphinTestRunner.kt @@ -0,0 +1,14 @@ +package com.github.damontecres.wholphin.test + +import android.app.Application +import android.content.Context +import androidx.test.runner.AndroidJUnitRunner +import dagger.hilt.android.testing.HiltTestApplication + +class WholphinTestRunner : AndroidJUnitRunner() { + override fun newApplication( + cl: ClassLoader?, + name: String?, + context: Context?, + ): Application = super.newApplication(cl, HiltTestApplication::class.java.name, context) +} diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..29b2eb75 --- /dev/null +++ b/app/src/debug/AndroidManifest.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + <uses-permission android:name="android.permission.INTERNET" /> + <uses-permission android:name="android.permission.RECORD_AUDIO" /> + <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> + <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> + <uses-permission + android:name="android.permission.WRITE_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission + android:name="android.permission.READ_EXTERNAL_STORAGE" + android:maxSdkVersion="28" /> + <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" /> + + <uses-feature + android:name="android.hardware.touchscreen" + android:required="false" /> + <uses-feature + android:name="android.software.leanback" + android:required="false" /> + <uses-feature + android:name="android.hardware.microphone" + android:required="false" /> + + <!-- Required for Android 11+ to query voice recognition availability --> + <queries> + <intent> + <action android:name="android.speech.action.RECOGNIZE_SPEECH" /> + </intent> + </queries> + + <application + android:allowBackup="true" + android:banner="@mipmap/ic_banner" + android:icon="@mipmap/ic_launcher" + android:label="@string/app_name" + android:supportsRtl="true" + android:theme="@style/Theme.Wholphin" + android:name=".WholphinApplication" + android:usesCleartextTraffic="true" + android:networkSecurityConfig="@xml/network_security_config"> + <activity + android:name=".MainActivity" + android:exported="true" + android:launchMode="singleTask" + android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <activity android:name=".test.TestActivity" + android:exported="true"> + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + + <category android:name="android.intent.category.LAUNCHER" /> + <category android:name="android.intent.category.LEANBACK_LAUNCHER" /> + </intent-filter> + </activity> + + <provider + android:name="androidx.core.content.FileProvider" + android:authorities="${applicationId}.provider" + android:exported="false" + android:grantUriPermissions="true"> + <meta-data + android:name="android.support.FILE_PROVIDER_PATHS" + android:resource="@xml/provider_paths" /> + </provider> + <provider + android:name="androidx.startup.InitializationProvider" + android:authorities="${applicationId}.androidx-startup" + tools:node="remove" /> + +<!-- <service--> +<!-- android:name=".WholphinDreamService"--> +<!-- android:exported="true"--> +<!-- android:label="@string/app_name"--> +<!-- android:permission="android.permission.BIND_DREAM_SERVICE">--> +<!-- <intent-filter>--> +<!-- <action android:name="android.service.dreams.DreamService" />--> +<!-- <category android:name="android.intent.category.DEFAULT" />--> +<!-- </intent-filter>--> +<!-- </service>--> + </application> + +</manifest> 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 5a6e446f..e2afbfa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -8,12 +8,9 @@ import android.view.WindowManager import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -21,28 +18,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.unit.dp import androidx.datastore.core.DataStore -import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope -import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator -import androidx.navigation3.runtime.NavEntry -import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator -import androidx.navigation3.ui.NavDisplay import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.MaterialTheme -import androidx.tv.material3.Surface import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreferences -import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.AppUpgradeHandler import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedInvalidationService @@ -62,17 +47,12 @@ import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient import com.github.damontecres.wholphin.services.tvprovider.TvProviderSchedulerService import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService -import com.github.damontecres.wholphin.ui.components.AppScreensaver import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO -import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.Destination -import com.github.damontecres.wholphin.ui.setup.SwitchServerContent -import com.github.damontecres.wholphin.ui.setup.SwitchUserContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler import dagger.hilt.android.AndroidEntryPoint @@ -80,6 +60,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -230,129 +211,19 @@ class MainActivity : AppCompatActivity() { true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, ) { - Surface( - modifier = - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), - shape = RectangleShape, - ) { -// val backStack = rememberNavBackStack(SetupDestination.Loading) -// setupNavigationManager.backStack = backStack - val backStack = setupNavigationManager.backStack - NavDisplay( - backStack = backStack, - onBack = { backStack.removeLastOrNull() }, - entryDecorators = - listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - entryProvider = { key -> - key as SetupDestination - NavEntry(key) { - when (key) { - SetupDestination.Loading -> { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - - SetupDestination.ServerList -> { - SwitchServerContent(Modifier.fillMaxSize()) - } - - is SetupDestination.UserList -> { - SwitchUserContent( - currentServer = key.server, - Modifier.fillMaxSize(), - ) - } - - is SetupDestination.AppContent -> { - LaunchedEffect(Unit) { - backdropService.clearBackdrop() - } - val current = key.current - ProvideLocalClock { - if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { - LaunchedEffect(Unit) { - try { - updateChecker.maybeShowUpdateToast( - appPreferences.updateUrl, - ) - } catch (ex: Exception) { - Timber.w( - ex, - "Exception during update check", - ) - } - } - } - val appPreferences by userPreferencesDataStore.data.collectAsState( - appPreferences, - ) - val preferences = - remember(appPreferences) { - UserPreferences(appPreferences) - } - var showContent by remember { - mutableStateOf(true) - } - LifecycleEventEffect(Lifecycle.Event.ON_STOP) { - if (!preferences.appPreferences.signInAutomatically) { - showContent = false - } - } - - if (showContent) { - val requestedDestination = - remember(intent) { - intent?.let(::extractDestination) - } - ApplicationContent( - user = current.user, - server = current.server, - startDestination = - requestedDestination - ?: Destination.Home(), - navigationManager = navigationManager, - preferences = preferences, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.size(200.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator( - color = MaterialTheme.colorScheme.border, - modifier = Modifier.align(Alignment.Center), - ) - } - } - } - } - } - } - }, - ) - val screenSaverState by screensaverService.state.collectAsState() - if (screenSaverState.enabled || screenSaverState.enabledTemp) { - AnimatedVisibility( - screenSaverState.show, - Modifier.fillMaxSize(), - ) { - AppScreensaver(appPreferences, Modifier.fillMaxSize()) - } + val requestedDestination = + remember(intent) { + intent?.let(::extractDestination) ?: Destination.Home() } - } + MainContent( + backStack = setupNavigationManager.backStack, + navigationManager = navigationManager, + appPreferences = appPreferences, + backdropService = backdropService, + screensaverService = screensaverService, + requestedDestination = requestedDestination, + modifier = Modifier.fillMaxSize(), + ) } } } @@ -400,6 +271,22 @@ class MainActivity : AppCompatActivity() { override fun onStart() { super.onStart() Timber.d("onStart") + + lifecycleScope.launchDefault { + val appPreferences = userPreferencesDataStore.data.first() + if (UpdateChecker.ACTIVE && appPreferences.autoCheckForUpdates) { + try { + updateChecker.maybeShowUpdateToast( + appPreferences.updateUrl, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Exception during update check", + ) + } + } + } } override fun onSaveInstanceState(outState: Bundle) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt new file mode 100644 index 00000000..982240a4 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/MainContent.kt @@ -0,0 +1,149 @@ +package com.github.damontecres.wholphin + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.ui.components.AppScreensaver +import com.github.damontecres.wholphin.ui.nav.ApplicationContent +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchUserContent +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock + +@Composable +fun MainContent( + backStack: MutableList<SetupDestination>, + navigationManager: NavigationManager, + appPreferences: AppPreferences, + backdropService: BackdropService, + screensaverService: ScreensaverService, + requestedDestination: Destination, + modifier: Modifier = Modifier, +) { + Surface( + modifier = + modifier + .background(MaterialTheme.colorScheme.background), + shape = RectangleShape, + ) { +// val backStack = rememberNavBackStack(SetupDestination.Loading) +// setupNavigationManager.backStack = backStack + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + entryDecorators = + listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = { key -> + key as SetupDestination + NavEntry(key) { + when (key) { + SetupDestination.Loading -> { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + SetupDestination.ServerList -> { + SwitchServerContent(Modifier.fillMaxSize()) + } + + is SetupDestination.UserList -> { + SwitchUserContent( + currentServer = key.server, + Modifier.fillMaxSize(), + ) + } + + is SetupDestination.AppContent -> { + LaunchedEffect(Unit) { + backdropService.clearBackdrop() + } + val current = key.current + ProvideLocalClock { + val preferences = + remember(appPreferences) { + UserPreferences(appPreferences) + } + var showContent by remember { + mutableStateOf(true) + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + if (!appPreferences.signInAutomatically) { + showContent = false + } + } + + if (showContent) { + ApplicationContent( + user = current.user, + server = current.server, + startDestination = requestedDestination, + navigationManager = navigationManager, + preferences = preferences, + modifier = Modifier.fillMaxSize(), + ) + } else { + Box( + modifier = Modifier.size(200.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.border, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } + } + } + } + }, + ) + val screenSaverState by screensaverService.state.collectAsState() + if (screenSaverState.enabled || screenSaverState.enabledTemp) { + AnimatedVisibility( + screenSaverState.show, + Modifier.fillMaxSize(), + ) { + AppScreensaver(appPreferences, Modifier.fillMaxSize()) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt index 610d0b88..2d786ed8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/ServerRepository.kt @@ -9,10 +9,12 @@ import androidx.lifecycle.map import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.EqualityMutableLiveData import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable @@ -41,6 +43,7 @@ class ServerRepository val serverDao: JellyfinServerDao, val apiClient: ApiClient, val userPreferencesDataStore: DataStore<AppPreferences>, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) { private var _current = EqualityMutableLiveData<CurrentUser?>(null) val current: LiveData<CurrentUser?> = _current @@ -57,7 +60,7 @@ class ServerRepository * The current user is removed */ suspend fun addAndChangeServer(server: JellyfinServer) { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.addOrUpdateServer(server) } apiClient.update(baseUrl = server.url, accessToken = null) @@ -71,7 +74,7 @@ class ServerRepository server: JellyfinServer, user: JellyfinUser, ): CurrentUser? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { if (server.id != user.serverId) { throw IllegalStateException("User is not part of the server") } @@ -126,7 +129,7 @@ class ServerRepository return null } val serverAndUsers = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.getServer(serverId) } if (serverAndUsers != null) { @@ -149,7 +152,7 @@ class ServerRepository } suspend fun fetchLastUsedServer(serverId: UUID?): JellyfinServer? = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverId?.let { serverDao.getServer(serverId)?.server } } @@ -163,7 +166,7 @@ class ServerRepository suspend fun changeUser( serverUrl: String, authenticationResult: AuthenticationResult, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val accessToken = authenticationResult.accessToken if (accessToken != null) { val authedUser = authenticationResult.user @@ -213,7 +216,7 @@ class ServerRepository } apiClient.update(accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteUser(user.serverId, user.id) } } @@ -233,7 +236,7 @@ class ServerRepository } apiClient.update(baseUrl = null, accessToken = null) } - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { serverDao.deleteServer(server.id) } } @@ -252,7 +255,7 @@ class ServerRepository suspend fun setUserPin( user: JellyfinUser, pin: String?, - ) = withContext(Dispatchers.IO) { + ) = withContext(ioDispatcher) { val newUser = user.copy(pin = pin) val updatedUser = serverDao.addOrUpdateUser(newUser) if (currentUser.value?.id == updatedUser.id && currentServer.value?.id == user.serverId) { @@ -265,7 +268,7 @@ class ServerRepository } suspend fun authorizeQuickConnect(code: String): Boolean = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { val userId = currentUser.value?.id if (userId == null) { Timber.e("No user logged in for Quick Connect authorization") diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt index bd95ea1d..e4910031 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SetupNavigationManager.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton class SetupNavigationManager @Inject constructor() { - var backStack: MutableList<NavKey> = mutableStateListOf(SetupDestination.Loading) + var backStack: MutableList<SetupDestination> = mutableStateListOf(SetupDestination.Loading) /** * Go to the specified [SetupDestination] 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 e3e42efa..991a8e98 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 @@ -18,6 +18,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -49,6 +50,14 @@ annotation class IoCoroutineScope @Retention(AnnotationRetention.BINARY) annotation class DefaultCoroutineScope +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class IoDispatcher + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DefaultDispatcher + @Module @InstallIn(SingletonComponent::class) object AppModule { @@ -62,12 +71,6 @@ object AppModule { version = BuildConfig.VERSION_NAME, ) - @Provides - @Singleton - fun deviceInfo( - @ApplicationContext context: Context, - ): DeviceInfo = androidDevice(context) - @StandardOkHttpClient @Provides @Singleton @@ -177,15 +180,29 @@ object AppModule { } } + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + @Provides @Singleton @IoCoroutineScope - fun ioCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = Dispatchers.Default @Provides @Singleton @DefaultCoroutineScope - fun defaultCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) @Provides @Singleton @@ -199,3 +216,13 @@ object AppModule { @StandardOkHttpClient okHttpClient: OkHttpClient, ) = SeerrApi(okHttpClient) } + +@Module +@InstallIn(SingletonComponent::class) +object DeviceModule { + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = androidDevice(context) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index b209ee59..5fd47621 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -382,7 +383,10 @@ fun AddServerCard( Surface( onClick = onClick, interactionSource = interactionSource, - modifier = Modifier.size(cardSize), + modifier = + Modifier + .size(cardSize) + .testTag("add_server"), shape = ClickableSurfaceDefaults.shape(shape = CircleShape), colors = ClickableSurfaceDefaults.colors( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt index 4902ca06..a4af8580 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerContent.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization @@ -352,6 +353,7 @@ fun SwitchServerContent( ), modifier = Modifier + .testTag("server_url_text") .focusRequester(textBoxFocusRequester) .fillMaxWidth(), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt index f9e7d0a3..08398a86 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/SwitchServerViewModel.kt @@ -11,12 +11,15 @@ import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.JellyfinServer import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.IoDispatcher import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.LoadingState import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -42,6 +45,8 @@ class SwitchServerViewModel val serverRepository: ServerRepository, val serverDao: JellyfinServerDao, val navigationManager: SetupNavigationManager, + @param:IoDispatcher private val ioDispatcher: CoroutineDispatcher, + @param:DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher, ) : ViewModel() { val servers = MutableLiveData<List<JellyfinServer>>(listOf()) val serverStatus = MutableLiveData<Map<UUID, ServerConnectionStatus>>(mapOf()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt index 43d3e45a..c1630900 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/CrashReportSenderFactory.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.util import android.content.Context import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.DeviceModule import com.google.auto.service.AutoService import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient @@ -49,7 +50,7 @@ class CrashReportSender : ReportSender { createJellyfin { this.context = context clientInfo = AppModule.clientInfo(context) - deviceInfo = AppModule.deviceInfo(context) + deviceInfo = DeviceModule.deviceInfo(context) apiClientFactory = okHttpFactory socketConnectionFactory = okHttpFactory minimumServerVersion = Jellyfin.minimumVersion diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt new file mode 100644 index 00000000..9aa51656 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestActivity.kt @@ -0,0 +1,7 @@ +package com.github.damontecres.wholphin.test + +import androidx.activity.ComponentActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class TestActivity : ComponentActivity() diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt new file mode 100644 index 00000000..66178a4e --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/BasicUiTests.kt @@ -0,0 +1,238 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.ScreensaverState +import com.github.damontecres.wholphin.services.SetupDestination +import com.github.damontecres.wholphin.services.SetupNavigationManager +import com.github.damontecres.wholphin.test.TestActivity +import com.github.damontecres.wholphin.ui.setup.SwitchServerContent +import com.github.damontecres.wholphin.ui.setup.SwitchServerViewModel +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.util.LoadingState +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.quickConnectApi +import org.jellyfin.sdk.api.operations.QuickConnectApi +import org.jellyfin.sdk.discovery.DiscoveryService +import org.jellyfin.sdk.discovery.RecommendedServerInfo +import org.jellyfin.sdk.discovery.RecommendedServerInfoScore +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PublicSystemInfo +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject + +@HiltAndroidTest +@Config(application = HiltTestApplication::class, sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class BasicUiTests { + @get:Rule(order = 0) + var hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule<TestActivity>() + + @Inject + lateinit var jellyfin: Jellyfin + + @Inject + lateinit var api: ApiClient + + @Inject + lateinit var setupNavigationManager: SetupNavigationManager + + lateinit var screensaverService: ScreensaverService + + lateinit var switchServerViewModel: SwitchServerViewModel + + val discovery: DiscoveryService = mockk() + + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setup() { + Dispatchers.setMain(TestModule.testDispatcher) + mockkStatic(Dispatchers::class) + + every { Dispatchers.IO } returns TestModule.testDispatcher + every { Dispatchers.Default } returns TestModule.testDispatcher + + hiltRule.inject() + screensaverService = mockk(relaxed = true) + every { screensaverService.state } returns + MutableStateFlow( + ScreensaverState( + false, + false, + false, + false, + ), + ) + + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns api + every { jellyfin.discovery } returns discovery + } + + @OptIn(ExperimentalCoroutinesApi::class) + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Tests successfully entering and submitting a server URL + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_success() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = UUID.randomUUID().toString(), + startupWizardCompleted = true, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + switchServerViewModel.addServerState.value.let { + if (it is LoadingState.Error) throw it.exception ?: Exception(it.message) + } + +// coVerify(exactly = 1) { discovery.getRecommendedServers("localhost") } + Assert.assertEquals(1, setupNavigationManager.backStack.size) + Assert.assertTrue(setupNavigationManager.backStack.last() is SetupDestination.UserList) + } + + /** + * Tests entering and submitting a server URL that returns an error + */ + @OptIn(ExperimentalTestApi::class) + @Test + fun test_enter_server_url_error() { + coEvery { discovery.getRecommendedServers("localhost") } returns + listOf( + RecommendedServerInfo( + address = "localhost", + responseTime = 50, + score = RecommendedServerInfoScore.GREAT, + issues = emptyList(), + systemInfo = + Result.success( + PublicSystemInfo( + id = null, // Invalid + startupWizardCompleted = false, + ), + ), + ), + ) + val quickConnectApi = mockk<QuickConnectApi>() + every { api.quickConnectApi } returns quickConnectApi + coEvery { quickConnectApi.getQuickConnectEnabled() } returns successResponse(true) + + composeTestRule.setContent { + WholphinTheme { + switchServerViewModel = hiltViewModel() + SwitchServerContent( + modifier = Modifier.fillMaxSize(), + viewModel = switchServerViewModel, + ) + } + } + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + composeTestRule.onNodeWithText("Add Server").assertIsDisplayed() + composeTestRule.onNodeWithTag("add_server").performKeyInput { + pressKey(Key.DirectionDown) // TODO fix focus + } + composeTestRule + .onNodeWithTag("add_server") + .assertIsFocused() + .performClickEnter() + + composeTestRule.onNodeWithText("Discovered Servers").assertIsDisplayed() + composeTestRule.onNodeWithText("Enter server address").performClickEnter() + composeTestRule.onNodeWithText("Enter Server IP or URL").assertIsDisplayed() + + composeTestRule.onNodeWithTag("server_url_text").performTextInput("localhost") + composeTestRule.onNodeWithText("Submit").requestFocus().performClickEnter() + + TestModule.testDispatcher.scheduler.advanceUntilIdle() + + Assert.assertTrue(switchServerViewModel.addServerState.value is LoadingState.Error) + + composeTestRule.onNodeWithText("Server returned invalid response").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt new file mode 100644 index 00000000..39705dd3 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/TestModule.kt @@ -0,0 +1,282 @@ +package com.github.damontecres.wholphin.ui + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import androidx.room.Room +import androidx.work.WorkManager +import com.github.damontecres.wholphin.BuildConfig +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.AppDatabase +import com.github.damontecres.wholphin.data.ItemPlaybackDao +import com.github.damontecres.wholphin.data.JellyfinServerDao +import com.github.damontecres.wholphin.data.LibraryDisplayInfoDao +import com.github.damontecres.wholphin.data.Migrations +import com.github.damontecres.wholphin.data.PlaybackEffectDao +import com.github.damontecres.wholphin.data.PlaybackLanguageChoiceDao +import com.github.damontecres.wholphin.data.SeerrServerDao +import com.github.damontecres.wholphin.data.ServerPreferencesDao +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.preferences.AppPreferences +import com.github.damontecres.wholphin.preferences.AppPreferencesSerializer +import com.github.damontecres.wholphin.services.SeerrApi +import com.github.damontecres.wholphin.services.hilt.AppModule +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import com.github.damontecres.wholphin.services.hilt.DatabaseModule +import com.github.damontecres.wholphin.services.hilt.DefaultCoroutineScope +import com.github.damontecres.wholphin.services.hilt.DefaultDispatcher +import com.github.damontecres.wholphin.services.hilt.DeviceModule +import com.github.damontecres.wholphin.services.hilt.IoCoroutineScope +import com.github.damontecres.wholphin.services.hilt.IoDispatcher +import com.github.damontecres.wholphin.services.hilt.StandardOkHttpClient +import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory +import com.github.damontecres.wholphin.util.RememberTabManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.test.StandardTestDispatcher +import okhttp3.OkHttpClient +import org.jellyfin.sdk.Jellyfin +import org.jellyfin.sdk.JellyfinOptions +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.api.okhttp.OkHttpFactory +import org.jellyfin.sdk.model.ClientInfo +import org.jellyfin.sdk.model.DeviceInfo +import timber.log.Timber +import javax.inject.Singleton + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DeviceModule::class, AppModule::class], +) +object TestModule { + val testDispatcher = StandardTestDispatcher() + + @Provides + @Singleton + fun deviceInfo( + @ApplicationContext context: Context, + ): DeviceInfo = DeviceInfo("test_device_id", "test_device") + + @Provides + @Singleton + fun clientInfo( + @ApplicationContext context: Context, + ): ClientInfo = + ClientInfo( + name = context.getString(R.string.app_name), + version = BuildConfig.VERSION_NAME, + ) + + @StandardOkHttpClient + @Provides + @Singleton + fun okHttpClient() = + OkHttpClient + .Builder() + .apply { + // TODO user agent, timeouts, logging, etc + }.build() + + @AuthOkHttpClient + @Provides + @Singleton + fun authOkHttpClient( + serverRepository: ServerRepository, + @StandardOkHttpClient okHttpClient: OkHttpClient, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ) = okHttpClient + .newBuilder() + .addInterceptor { + val request = it.request() + val newRequest = + serverRepository.currentUser.value?.accessToken?.let { token -> + request + .newBuilder() + .addHeader( + "Authorization", + AuthorizationHeaderBuilder.buildHeader( + clientName = clientInfo.name, + clientVersion = clientInfo.version, + deviceId = deviceInfo.id, + deviceName = deviceInfo.name, + accessToken = token, + ), + ).build() + } + it.proceed(newRequest ?: request) + }.build() + + @Provides + @Singleton + fun okHttpFactory( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient)) + + @Provides + @Singleton + fun jellyfin( + okHttpFactory: CoroutineContextApiClientFactory, + @ApplicationContext context: Context, + clientInfo: ClientInfo, + deviceInfo: DeviceInfo, + ): Jellyfin { + val jellyfin: Jellyfin = mockk() + every { jellyfin.clientInfo } returns clientInfo + every { jellyfin.deviceInfo } returns deviceInfo + every { jellyfin.options } returns + JellyfinOptions( + context, + clientInfo, + deviceInfo, + okHttpFactory, + okHttpFactory, + Jellyfin.minimumVersion, + ) + every { jellyfin.createApi(any()) } returns apiClient(jellyfin) + every { jellyfin.createApi(any(), any(), any(), any(), any()) } returns apiClient(jellyfin) + return jellyfin + } + + @Provides + @Singleton + fun apiClient(jellyfin: Jellyfin): ApiClient { + val api: ApiClient = mockk() + every { api.clientInfo } returns jellyfin.clientInfo!! + every { api.deviceInfo } returns jellyfin.deviceInfo!! + every { api.update(any(), any(), any(), any()) } returns Unit + return api + } + + /** + * Implementation of [RememberTabManager] which remembers by server, user, & item + */ + @Provides + @Singleton + fun rememberTabManager( + serverRepository: ServerRepository, + appPreference: DataStore<AppPreferences>, + @IoCoroutineScope scope: CoroutineScope, + ): RememberTabManager = mockk() + + @Provides + @Singleton + @IoDispatcher + fun ioDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @IoCoroutineScope + fun ioCoroutineScope( + @IoDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + @DefaultDispatcher + fun defaultDispatcher(): CoroutineDispatcher = testDispatcher + + @Provides + @Singleton + @DefaultCoroutineScope + fun defaultCoroutineScope( + @DefaultDispatcher dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) + + @Provides + @Singleton + fun workManager( + @ApplicationContext context: Context, + ): WorkManager = mockk() + + @Provides + @Singleton + fun seerrApi( + @StandardOkHttpClient okHttpClient: OkHttpClient, + ): SeerrApi = mockk() +} + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DatabaseModule::class], +) +object TestDatabaseModule { + @Module + @InstallIn(SingletonComponent::class) + object DatabaseModule { + @Provides + @Singleton + fun database( + @ApplicationContext context: Context, + ): AppDatabase = + Room + .inMemoryDatabaseBuilder( + context, + AppDatabase::class.java, + ).addMigrations(Migrations.Migrate2to3) + .allowMainThreadQueries() + .setQueryCallback({ sqlQuery, args -> + Timber.v("sqlQuery=$sqlQuery, args=$args") + }, Dispatchers.IO.asExecutor()) + .build() + + @Provides + @Singleton + fun serverDao(db: AppDatabase): JellyfinServerDao = db.serverDao() + + @Provides + @Singleton + fun itemPlaybackDao(db: AppDatabase): ItemPlaybackDao = db.itemPlaybackDao() + + @Provides + @Singleton + fun serverPreferencesDao(db: AppDatabase): ServerPreferencesDao = db.serverPreferencesDao() + + @Provides + @Singleton + fun libraryDisplayInfoDao(db: AppDatabase): LibraryDisplayInfoDao = db.libraryDisplayInfoDao() + + @Provides + @Singleton + fun playbackLanguageChoiceDao(db: AppDatabase): PlaybackLanguageChoiceDao = db.playbackLanguageChoiceDao() + + @Provides + @Singleton + fun seerrServerDao(db: AppDatabase): SeerrServerDao = db.seerrServerDao() + + @Provides + @Singleton + fun playbackEffectDao(db: AppDatabase): PlaybackEffectDao = db.playbackEffectDao() + + @Provides + @Singleton + fun userPreferencesDataStore( + @ApplicationContext context: Context, + userPreferencesSerializer: AppPreferencesSerializer, + ): DataStore<AppPreferences> = + DataStoreFactory.create( + serializer = userPreferencesSerializer, + produceFile = { context.dataStoreFile("app_preferences.pb") }, + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { AppPreferences.getDefaultInstance() }, + ), + ) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt new file mode 100644 index 00000000..d0bc8532 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/Utils.kt @@ -0,0 +1,16 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import org.jellyfin.sdk.api.client.Response + +@OptIn(ExperimentalTestApi::class) +fun SemanticsNodeInteraction.performClickEnter() = + performKeyInput { + pressKey(Key.DirectionCenter) + } + +fun <T> successResponse(content: T) = Response(content, 200, emptyMap()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 06a1b888..e477d360 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -43,6 +43,7 @@ paletteKtx = "1.0.0" kotlinxCoroutinesTest = "1.10.2" coreTesting = "2.2.0" openapi-generator = "7.20.0" +runner = "1.7.0" [libraries] aboutlibraries-core = { module = "com.mikepenz:aboutlibraries-core", version.ref = "aboutLibraries" } @@ -72,6 +73,7 @@ androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecy androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx-tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" } @@ -79,6 +81,7 @@ auto-service-ksp = { module = "dev.zacsweers.autoservice:auto-service-ksp", vers desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } kache = { module = "com.mayakapps.kache:kache", version.ref = "kache" } kache-file = { module = "com.mayakapps.kache:file-kache", version.ref = "kache" } mockk-agent = { module = "io.mockk:mockk-agent", version.ref = "mockk" } @@ -129,6 +132,7 @@ androidx-room-testing = { group = "androidx.room", name = "room-testing", versio androidx-palette-ktx = { group = "androidx.palette", name = "palette-ktx", version.ref = "paletteKtx" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } androidx-core-testing = { module = "androidx.arch.core:core-testing", version.ref = "coreTesting" } +androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From be90a42c57d41480f2e612fd5e138a956b32f2d7 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:46:55 -0500 Subject: [PATCH 171/176] Various fixes (#1030) ## Description Fixes several small issues throughout the app - Adds 1440p as a resolution option - Removes the delay when d-pad seeking on the seek bar & reduces it slightly when controls are hidden - Fixes padding one home customize pages - Adds a title to the search for dialog ### Related issues Fixes #1017 ### Testing Emulator mostly ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/WholphinDreamService.kt | 23 +++++++++------- .../wholphin/ui/components/AppScreensaver.kt | 1 + .../wholphin/ui/components/Dialogs.kt | 3 +++ .../ui/detail/search/SearchForDialog.kt | 26 ++++++++++++------- .../ui/main/settings/HomeSettingsPage.kt | 1 + .../wholphin/ui/playback/SeekAcceleration.kt | 2 +- .../wholphin/ui/playback/SeekBar.kt | 12 ++------- .../wholphin/ui/util/StreamFormatting.kt | 24 ++++++++--------- app/src/main/jni/event.cpp | 1 + app/src/main/res/values/strings.xml | 1 + .../wholphin/test/FormattingTests.kt | 23 ++++++++++++++++ 11 files changed, 75 insertions(+), 42 deletions(-) create mode 100644 app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt index 1c0e4e0e..4d77c3a8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/WholphinDreamService.kt @@ -23,6 +23,7 @@ import com.github.damontecres.wholphin.services.ScreensaverService import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.ui.components.AppScreensaverContent import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import javax.inject.Inject @@ -69,16 +70,18 @@ class WholphinDreamService : } prefs?.let { prefs -> WholphinTheme(appThemeColors = prefs.appPreferences.interfacePreferences.appThemeColors) { - val screensaverPrefs = - prefs.appPreferences.interfacePreferences.screensaverPreference - val currentItem by itemFlow.collectAsState(null) - AppScreensaverContent( - currentItem = currentItem, - showClock = screensaverPrefs.showClock, - duration = screensaverPrefs.duration.milliseconds, - animate = screensaverPrefs.animate, - modifier = Modifier.fillMaxSize(), - ) + ProvideLocalClock { + val screensaverPrefs = + prefs.appPreferences.interfacePreferences.screensaverPreference + val currentItem by itemFlow.collectAsState(null) + AppScreensaverContent( + currentItem = currentItem, + showClock = screensaverPrefs.showClock, + duration = screensaverPrefs.duration.milliseconds, + animate = screensaverPrefs.animate, + modifier = Modifier.fillMaxSize(), + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt index e55a2aa6..d26675b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/AppScreensaver.kt @@ -165,6 +165,7 @@ fun AppScreensaverContent( ) { Text( text = currentItem?.title ?: "", + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.displaySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt index a1f2362a..a96bc859 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Dialogs.kt @@ -656,6 +656,9 @@ fun chooseStream( ) add( DialogItem( + leadingContent = { + SelectedLeadingContent(currentIndex == TrackIndex.ONLY_FORCED) + }, headlineContent = { Text(text = stringResource(R.string.only_forced_subtitles)) }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt index 6717527e..09df419c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/search/SearchForDialog.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -91,10 +92,25 @@ fun SearchForContent( } } } + val titleRes = + remember { + when (searchType) { + BaseItemKind.BOX_SET -> R.string.collections + BaseItemKind.PLAYLIST -> R.string.playlists + else -> null + } + } + val title = titleRes?.let { stringResource(it) } ?: "" Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier, ) { + Text( + text = stringResource(R.string.search_for, title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) Box( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth(), @@ -190,16 +206,8 @@ fun SearchForContent( text = stringResource(R.string.no_results), ) } else { - val titleRes = - remember { - when (searchType) { - BaseItemKind.BOX_SET -> R.string.collections - BaseItemKind.PLAYLIST -> R.string.playlists - else -> null - } - } ItemRow( - title = titleRes?.let { stringResource(it) } ?: "", + title = "", items = st.items, onClickItem = { _, item -> onClick.invoke(item) }, onLongClickItem = { _, _ -> }, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt index 1185f719..fb025c02 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsPage.kt @@ -240,6 +240,7 @@ fun HomeSettingsPage( onClick = { type -> addRow { viewModel.addFavoriteRow(type) } }, + modifier = destModifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt index 1349a642..6925d5cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekAcceleration.kt @@ -1,6 +1,6 @@ package com.github.damontecres.wholphin.ui.playback -internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 12 +internal const val HOLD_TO_SEEK_REPEAT_START_COUNT = 8 /** * Shared seek acceleration profile for hold-to-seek behavior. diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt index e34d91af..92c6114b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/SeekBar.kt @@ -182,14 +182,10 @@ fun SeekBarDisplay( KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount if (repeatCount > 0) { - if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { - leftHandledByRepeat = false - return@onPreviewKeyEvent true - } leftHandledByRepeat = true onLeft.invoke( calculateSeekAccelerationMultiplier( - repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + repeatCount = repeatCount, durationMs = durationMs, ), ) @@ -217,14 +213,10 @@ fun SeekBarDisplay( KeyEventType.KeyDown -> { val repeatCount = event.nativeKeyEvent.repeatCount if (repeatCount > 0) { - if (repeatCount < HOLD_TO_SEEK_REPEAT_START_COUNT) { - rightHandledByRepeat = false - return@onPreviewKeyEvent true - } rightHandledByRepeat = true onRight.invoke( calculateSeekAccelerationMultiplier( - repeatCount = repeatCount - HOLD_TO_SEEK_REPEAT_START_COUNT, + repeatCount = repeatCount, durationMs = durationMs, ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt index ff4e62da..ca6e4027 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/StreamFormatting.kt @@ -27,18 +27,18 @@ object StreamFormatting { resolutionString(height, width, interlaced) } else { when { - width <= 256 && height <= 144 -> "144" + interlaced(interlaced) - width <= 426 && height <= 240 -> "240" + interlaced(interlaced) - width <= 640 && height <= 360 -> "360" + interlaced(interlaced) - width <= 682 && height <= 384 -> "384" + interlaced(interlaced) - width <= 720 && height <= 404 -> "404" + interlaced(interlaced) - width <= 854 && height <= 480 -> "480" + interlaced(interlaced) - width <= 960 && height <= 544 -> "540" + interlaced(interlaced) - width <= 1024 && height <= 576 -> "576" + interlaced(interlaced) - width <= 1280 && height <= 962 -> "720" + interlaced(interlaced) - width <= 2560 && height <= 1440 -> "1080" + interlaced(interlaced) - width <= 4096 && height <= 3072 -> "4K" - width <= 8192 && height <= 6144 -> "8K" + width > 5120 || height > 4320 -> "8K" + width > 2560 || height > 1440 -> "4K" + width > 1920 || height > 1080 -> "1440" + interlaced(interlaced) + width > 1280 || height > 962 -> "1080" + interlaced(interlaced) + width > 1024 || height > 576 -> "720" + interlaced(interlaced) + width > 960 || height > 544 -> "576" + interlaced(interlaced) + width > 845 || height > 480 -> "540" + interlaced(interlaced) + width > 720 || height > 404 -> "480" + interlaced(interlaced) + width > 682 || height > 384 -> "404" + interlaced(interlaced) + width > 640 || height > 360 -> "384" + interlaced(interlaced) + width > 426 || height > 240 -> "360" + interlaced(interlaced) + width > 256 || height > 144 -> "240" + interlaced(interlaced) else -> height.toString() + interlaced(interlaced) } } diff --git a/app/src/main/jni/event.cpp b/app/src/main/jni/event.cpp index b092b661..26c69300 100644 --- a/app/src/main/jni/event.cpp +++ b/app/src/main/jni/event.cpp @@ -107,6 +107,7 @@ void *event_thread(void *arg) case MPV_EVENT_END_FILE: mp_end_file = (mpv_event_end_file*)mp_event->data; sendEndFileEventToJava(env, mp_end_file); + break; default: ALOGV("event: %s\n", mpv_event_name(mp_event->event_id)); sendEventToJava(env, mp_event->event_id); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc1ebf9d..c7a2fb92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -718,5 +718,6 @@ <string name="mixer">Mixer</string> <string name="creator">Creator</string> <string name="artist">Artist</string> + <string name="search_for">Search %s</string> </resources> diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt new file mode 100644 index 00000000..b0a8810f --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/test/FormattingTests.kt @@ -0,0 +1,23 @@ +package com.github.damontecres.wholphin.test + +import com.github.damontecres.wholphin.ui.util.StreamFormatting.resolutionString +import org.junit.Assert +import org.junit.Test + +class FormattingTests { + @Test + fun testResolutionStrings() { + Assert.assertEquals("4K", resolutionString(3840, 2160, false)) + Assert.assertEquals("1080p", resolutionString(1920, 1080, false)) + Assert.assertEquals("720p", resolutionString(1280, 720, false)) + Assert.assertEquals("480i", resolutionString(640, 480, true)) + + Assert.assertEquals("576p", resolutionString(1024, 576, false)) + Assert.assertEquals("576p", resolutionString(960, 576, false)) + + // 21:9 + Assert.assertEquals("1080p", resolutionString(1920, 822, false)) + + Assert.assertEquals("1440p", resolutionString(2560, 1440, false)) + } +} From 6ea5fd2121552f727c47780c679a2a3226fa2988 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:34:44 -0500 Subject: [PATCH 172/176] Update actions/upload-artifact action to v7 (#1009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [actions/upload-artifact](https://redirect.github.com/actions/upload-artifact) | action | major | `v6` → `v7` | --- ### Release Notes <details> <summary>actions/upload-artifact (actions/upload-artifact)</summary> ### [`v7`](https://redirect.github.com/actions/upload-artifact/compare/v6...v7) [Compare Source](https://redirect.github.com/actions/upload-artifact/compare/v6...v7) </details> --- ### Configuration 📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/damontecres/Wholphin). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4zNi4yIiwidXBkYXRlZEluVmVyIjoiNDMuMzYuMiIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==--> Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c654dad9..9611c810 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: echo "SHA256 checksums:" find app/build/outputs \( -name '*.apk' -or -name '*.aab' \) -print0 | xargs -0 sha256sum - name: Upload AAB - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: AAB path: | From 6867ba76575e4143326706d61ac9212e96b2fdd1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:35:11 -0500 Subject: [PATCH 173/176] Update Dependencies (#1013) --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e477d360..b60ca106 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ kotlin = "2.3.10" ksp = "2.3.6" coreKtx = "1.17.0" appcompat = "1.7.1" -composeBom = "2026.02.00" +composeBom = "2026.02.01" mockk = "1.14.9" robolectric = "4.16.1" multiplatformMarkdownRenderer = "0.39.2" @@ -33,7 +33,7 @@ material3AdaptiveNav3 = "1.0.0-alpha03" protobuf = "0.9.6" datastore = "1.2.0" kotlinx-serialization = "1.10.0" -protobuf-javalite = "4.33.5" +protobuf-javalite = "4.34.0" hilt = "2.59.2" room = "2.8.4" preferenceKtx = "1.2.1" From ce6461d23ba3ba52e915db2eb71b1427955a20b0 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:11:09 -0500 Subject: [PATCH 174/176] Fixes for Jellyseerr login issues (#1031) ## Description Fixes some issues with Jellyseerr login. Also makes error message more clear by showing the URLs that were tested and the error that occurred. Also fixes an issue where you could not remove a Jellyseerr server if it was previously configured but can no longer connect. ### Related issues Fixes #1028 Should fix #1022 Related to #747 ### Testing Emulator, tested adding and removing valid & invalid IPs ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/data/SeerrServerDao.kt | 2 +- .../services/SeerrServerRepository.kt | 99 +++++++++-------- .../ui/preferences/PreferencesContent.kt | 1 + .../wholphin/ui/setup/seerr/AddSeerrServer.kt | 16 ++- .../ui/setup/seerr/AddSeerrServerDialog.kt | 13 ++- .../ui/setup/seerr/SwitchSeerrViewModel.kt | 102 ++++++++++-------- .../damontecres/wholphin/test/TestSeerr.kt | 28 +++++ 7 files changed, 165 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt index 59f5d9ea..3a2c325c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/SeerrServerDao.kt @@ -47,7 +47,7 @@ interface SeerrServerDao { suspend fun deleteUser( serverId: Int, jellyfinUserRowId: Int, - ) + ): Int suspend fun deleteUser(user: SeerrUser) = deleteUser(user.serverId, user.jellyfinUserRowId) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt index 78ff7d3b..970e80c3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrServerRepository.kt @@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.api.seerr.model.PublicSettings import com.github.damontecres.wholphin.api.seerr.model.User import com.github.damontecres.wholphin.data.SeerrServerDao import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinUser import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.SeerrServer @@ -24,8 +25,10 @@ import dagger.hilt.android.scopes.ActivityScoped import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.coroutines.supervisorScope import okhttp3.OkHttpClient import timber.log.Timber import javax.inject.Inject @@ -162,7 +165,7 @@ class SeerrServerRepository apiKey, okHttpClient .newBuilder() - .connectTimeout(5.seconds) + .connectTimeout(2.seconds) .readTimeout(6.seconds) .build(), ) @@ -170,10 +173,11 @@ class SeerrServerRepository return LoadingState.Success } - suspend fun removeServer() { - val current = (_connection.value as? SeerrConnectionStatus.Success)?.current ?: return - seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) + suspend fun removeServerForCurrentUser(): Boolean { + val current = current.firstOrNull() ?: return false + val rows = seerrServerDao.deleteUser(current.server.id, current.user.jellyfinUserRowId) clear() + return rows > 0 } } @@ -266,47 +270,56 @@ class UserSwitchListener seerrServerRepository.clear() homeSettingsService.currentSettings.update { HomePageResolvedSettings.EMPTY } if (user != null) { - // Check for home settings - launchIO { - homeSettingsService.loadCurrentSettings(user.id) - } - // Check for seerr server - launchIO { - seerrServerDao - .getUsersByJellyfinUser(user.rowId) - .lastOrNull() - ?.let { seerrUser -> - val server = - seerrServerDao.getServer(seerrUser.serverId)?.server - if (server != null) { - Timber.i("Found a seerr user & server") - try { - seerrApi.update(server.url, seerrUser.credential) - val userConfig = - if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { - login( - seerrApi.api, - seerrUser.authMethod, - seerrUser.username, - seerrUser.password, - ) - } else { - seerrApi.api.usersApi.authMeGet() - } - seerrServerRepository.set(server, seerrUser, userConfig) - } catch (ex: Exception) { - Timber.w( - ex, - "Error logging into %s", - server.url, - ) - seerrServerRepository.error(server.url, ex) - } - } - } - } + switchUser(user) } } } } + + private suspend fun switchUser(user: JellyfinUser) = + supervisorScope { + // Check for home settings + launchIO { + homeSettingsService.loadCurrentSettings(user.id) + } + // Check for seerr server + launchIO { + seerrServerDao + .getUsersByJellyfinUser(user.rowId) + .lastOrNull() + ?.let { seerrUser -> + val server = + seerrServerDao.getServer(seerrUser.serverId)?.server + if (server != null) { + Timber.i("Found a seerr user & server") + try { + seerrApi.update(server.url, seerrUser.credential) + val userConfig = + if (seerrUser.authMethod != SeerrAuthMethod.API_KEY) { + login( + seerrApi.api, + seerrUser.authMethod, + seerrUser.username, + seerrUser.password, + ) + } else { + seerrApi.api.usersApi.authMeGet() + } + seerrServerRepository.set( + server, + seerrUser, + userConfig, + ) + } catch (ex: Exception) { + Timber.w( + ex, + "Error logging into %s", + server.url, + ) + seerrServerRepository.error(server.url, ex) + } + } + } + } + } } 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 d9f1fb4d..729e7085 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 @@ -554,6 +554,7 @@ fun PreferencesContent( currentUsername = currentUser?.name, status = status, onSubmit = seerrVm::submitServer, + onResetStatus = seerrVm::resetStatus, onDismissRequest = { seerrDialogMode = SeerrDialogMode.None }, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 81e06fb8..5fa8c667 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.focusGroup 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.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -66,15 +65,19 @@ fun AddSeerrServerApiKey( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) + val labelWidth = 90.dp Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.align(Alignment.CenterHorizontally), ) { Text( text = stringResource(R.string.url), - modifier = Modifier.padding(end = 8.dp), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = url, @@ -105,7 +108,10 @@ fun AddSeerrServerApiKey( ) { Text( text = stringResource(R.string.api_key), - modifier = Modifier.padding(end = 8.dp), + modifier = + Modifier + .width(labelWidth) + .padding(end = 8.dp), ) EditTextBox( value = apiKey, @@ -173,7 +179,7 @@ fun AddSeerrServerUsername( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.align(Alignment.CenterHorizontally), ) val labelWidth = 90.dp Row( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index 9566b88b..d84b98db 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -1,12 +1,16 @@ package com.github.damontecres.wholphin.ui.setup.seerr +import androidx.compose.foundation.layout.widthIn 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.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.SeerrAuthMethod import com.github.damontecres.wholphin.ui.components.BasicDialog @@ -20,6 +24,7 @@ fun AddSeerServerDialog( currentUsername: String?, status: LoadingState, onSubmit: (url: String, username: String, passwordOrApiKey: String, method: SeerrAuthMethod) -> Unit, + onResetStatus: () -> Unit, onDismissRequest: () -> Unit, ) { var authMethod by remember { mutableStateOf<SeerrAuthMethod?>(null) } @@ -34,6 +39,7 @@ fun AddSeerServerDialog( -> { BasicDialog( onDismissRequest = { authMethod = null }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { AddSeerrServerUsername( onSubmit = { url, username, password -> @@ -41,6 +47,7 @@ fun AddSeerServerDialog( }, username = currentUsername ?: "", status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -54,6 +61,7 @@ fun AddSeerServerDialog( onSubmit.invoke(url, "", apiKey, SeerrAuthMethod.API_KEY) }, status = status, + modifier = Modifier.widthIn(min = 320.dp), ) } } @@ -61,7 +69,10 @@ fun AddSeerServerDialog( null -> { ChooseSeerrLoginType( onDismissRequest = onDismissRequest, - onChoose = { authMethod = it }, + onChoose = { + onResetStatus.invoke() + authMethod = it + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt index 4f93217a..0f62f12f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/SwitchSeerrViewModel.kt @@ -50,63 +50,73 @@ class SwitchSeerrViewModel serverConnectionStatus.update { LoadingState.Error("Invalid URL", ex) } return@launchIO } - var result: LoadingState = LoadingState.Error("No url") + Timber.v("Urls to try: %s", urls) + val results = mutableMapOf<HttpUrl, LoadingState>() for (url in urls) { Timber.d("Trying %s", url) - result = - try { - seerrServerRepository.testConnection( - authMethod = authMethod, - url = url.toString(), - username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, - passwordOrApiKey = passwordOrApiKey, - ) - } catch (ex: ClientException) { - Timber.w(ex, "ClientException logging in") - if (ex.statusCode == 401 || ex.statusCode == 403) { - showToast(context, "Invalid credentials") - result = LoadingState.Error("Invalid credentials", ex) - break - } else { - LoadingState.Error("Could not connect with URL") - } - } catch (ex: Exception) { - Timber.w(ex, "Exception logging in") - LoadingState.Error(ex) - } - if (result is LoadingState.Success) { - when (authMethod) { - SeerrAuthMethod.LOCAL, - SeerrAuthMethod.JELLYFIN, - -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - authMethod, - username, - passwordOrApiKey, - ) - } - - SeerrAuthMethod.API_KEY -> { - seerrServerRepository.addAndChangeServer( - url.toString(), - passwordOrApiKey, - ) - } - } + try { + seerrServerRepository.testConnection( + authMethod = authMethod, + url = url.toString(), + username = username.takeIf { authMethod != SeerrAuthMethod.API_KEY }, + passwordOrApiKey = passwordOrApiKey, + ) + results[url] = LoadingState.Success break + } catch (ex: ClientException) { + Timber.w(ex, "ClientException logging in %s", url) + if (ex.statusCode == 401 || ex.statusCode == 403) { + showToast(context, "Invalid credentials") + results[url] = LoadingState.Error("Invalid credentials", ex) + } else { + results[url] = LoadingState.Error("Could not connect with URL") + } + } catch (ex: Exception) { + Timber.w(ex, "ClientException logging in %s", url) + results[url] = LoadingState.Error(ex) } } - if (result is LoadingState.Error) { - showToast(context, "Error: ${result.message}") + val result = results.filter { (url, state) -> state is LoadingState.Success } + if (result.isNotEmpty()) { + val url = result.keys.first() + when (authMethod) { + SeerrAuthMethod.LOCAL, + SeerrAuthMethod.JELLYFIN, + -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + authMethod, + username, + passwordOrApiKey, + ) + } + + SeerrAuthMethod.API_KEY -> { + seerrServerRepository.addAndChangeServer( + url.toString(), + passwordOrApiKey, + ) + } + } + } else { + val message = + results + .map { (url, state) -> + val s = state as? LoadingState.Error + "$url - ${s?.localizedMessage}" + }.joinToString("\n") + showToast(context, "Could not connect") + serverConnectionStatus.update { LoadingState.Error(message) } } - serverConnectionStatus.update { result } } } fun removeServer() { viewModelScope.launchIO { - seerrServerRepository.removeServer() + val result = seerrServerRepository.removeServerForCurrentUser() + if (!result) { + showToast(context, "Could not remove server") + } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt index 76893f8c..a4acf93f 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestSeerr.kt @@ -62,4 +62,32 @@ class TestSeerr { ) Assert.assertEquals(expected, urls) } + + @Test + fun testCreateUrls5() { + val urls = + createUrls("10.0.0.2:443") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:443/api/v1", + "https://10.0.0.2/api/v1", + ) + Assert.assertEquals(expected, urls) + } + + @Test + fun testCreateUrls6() { + val urls = + createUrls("10.0.0.2:8080") + .map { it.toString() } + + val expected = + listOf( + "http://10.0.0.2:8080/api/v1", + "https://10.0.0.2:8080/api/v1", + ) + Assert.assertEquals(expected, urls) + } } From 354577e2e89e6f950c2641f27eae120d62659e19 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:08 -0500 Subject: [PATCH 175/176] Delete media follow up (#1040) ## Description A follow up to #1014 to add the delete option in more context menus. Also improves the "reaction" logic which updates previous pages from deletions on later pages. ### Related issues Addresses https://github.com/damontecres/Wholphin/pull/1014#issuecomment-3987205355 Related to #104 ### Testing Emulator ## Screenshots N/A ## AI or LLM usage None --- .../services/MediaManagementService.kt | 14 +++- .../ui/components/CollectionFolderGrid.kt | 74 ++++++++++++++++--- .../wholphin/ui/components/PlayButtons.kt | 14 ++++ .../ui/components/RecommendedContent.kt | 39 +++++++++- .../ui/components/RecommendedMovie.kt | 3 + .../ui/components/RecommendedTvShow.kt | 3 + .../wholphin/ui/detail/DetailUtils.kt | 6 +- .../detail/discover/DiscoverMovieDetails.kt | 10 --- .../ui/detail/episode/EpisodeDetails.kt | 20 +++++ .../ui/detail/episode/EpisodeViewModel.kt | 13 ++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 6 ++ .../ui/detail/series/FocusedEpisodeFooter.kt | 4 + .../ui/detail/series/SeriesDetails.kt | 1 + .../ui/detail/series/SeriesOverview.kt | 2 + .../ui/detail/series/SeriesOverviewContent.kt | 4 + .../ui/detail/series/SeriesViewModel.kt | 23 ++++++ .../damontecres/wholphin/ui/main/HomePage.kt | 17 +++++ .../wholphin/ui/main/HomeViewModel.kt | 36 +++++++++ 18 files changed, 261 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt index f3aff8c6..a13881a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/MediaManagementService.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,11 +41,16 @@ class MediaManagementService val deletedItemFlow: SharedFlow<DeletedItem> = _deletedItemFlow suspend fun canDelete(item: BaseItem): Boolean { + val appPreferences = userPreferencesService.getCurrent().appPreferences + return canDelete(item, appPreferences) + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean { Timber.v("canDelete %s: %s", item.id, item.canDelete) - val enabled = - userPreferencesService - .getCurrent() - .appPreferences.interfacePreferences.enableMediaManagement + val enabled = appPreferences.interfacePreferences.enableMediaManagement return enabled && item.canDelete && if (item.type == BaseItemKind.RECORDING) { 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 6eae4f26..8415c7a6 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 @@ -57,6 +57,7 @@ import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilterOverride import com.github.damontecres.wholphin.data.model.LibraryDisplayInfo +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -65,6 +66,7 @@ import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus import com.github.damontecres.wholphin.ui.SlimItemFields @@ -77,6 +79,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -84,6 +87,7 @@ import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.scale import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.ui.util.FilterUtils @@ -100,6 +104,9 @@ import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -202,18 +209,34 @@ class CollectionFolderViewModel loading.setValueOnMain(DataLoadingState.Error(ex)) } } - viewModelScope.launchDefault { - mediaManagementService.deletedItemFlow.collect { deletedItem -> - val pager = - ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) - position.let { - Timber.v("Item deleted: position=%s, id=%s", it, deletedItem.item.id) - val item = pager?.get(it) - if (item?.id == deletedItem.item.id) { - pager.refreshPagesAfter(position) - } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + refreshAfterDelete(position, deletedItem.item) + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) + } + + private suspend fun refreshAfterDelete( + position: Int, + deletedItem: BaseItem, + ) { + try { + val pager = + ((loading.value as? DataLoadingState.Success)?.data as? ApiRequestPager<*>) + position.let { + Timber.v("Item deleted: position=%s, id=%s", it, itemId) + val item = pager?.get(it) + // Exact item deleted (eg a movie) or deleted item was within the series + if (item?.id == deletedItem.id || + equalsNotNull(item?.data?.id, deletedItem.data.seriesId) + ) { + pager?.refreshPagesAfter(position) } } + } catch (ex: Exception) { + Timber.e(ex, "Error refreshing after deleted item %s", itemId) + showToast(context, "Error refreshing after item deleted") } } @@ -491,6 +514,22 @@ class CollectionFolderViewModel } } } + + fun deleteItem( + index: Int, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + refreshAfterDelete(index, item) + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } /** @@ -578,6 +617,7 @@ fun CollectionFolderGrid( var moreDialog by remember { mutableStateOf<Optional<PositionItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<PositionItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) when (val state = loading) { @@ -713,6 +753,7 @@ fun CollectionFolderGrid( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -727,6 +768,9 @@ fun CollectionFolderGrid( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = PositionItem(position, item) + }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -751,6 +795,16 @@ fun CollectionFolderGrid( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } @OptIn(ExperimentalFoundationApi::class) 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 570d411e..75a3147c 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 @@ -72,12 +72,14 @@ fun ExpandablePlayButtons( resumePosition: Duration, watched: Boolean, favorite: Boolean, + canDelete: Boolean, trailers: List<Trailer>?, playOnClick: (position: Duration) -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, trailerOnClick: (Trailer) -> Unit, + deleteOnClick: () -> Unit, buttonOnFocusChanged: (FocusState) -> Unit, modifier: Modifier = Modifier, ) { @@ -158,6 +160,16 @@ fun ExpandablePlayButtons( ) } } + if (canDelete) { + item("delete") { + DeleteButton( + onClick = deleteOnClick, + modifier = + Modifier + .onFocusChanged(buttonOnFocusChanged), + ) + } + } // More button item("more") { @@ -424,6 +436,8 @@ private fun ExpandablePlayButtonsPreview() { buttonOnFocusChanged = {}, trailers = listOf(), trailerOnClick = {}, + canDelete = true, + deleteOnClick = {}, modifier = Modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt index 908574e3..c3d1a4b6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/RecommendedContent.kt @@ -19,11 +19,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn @@ -31,6 +34,7 @@ import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.nav.Destination @@ -51,6 +55,7 @@ abstract class RecommendedViewModel( val favoriteWatchManager: FavoriteWatchManager, val mediaReportService: MediaReportService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { abstract fun init() @@ -117,6 +122,25 @@ abstract class RecommendedViewModel( } update(title, row) } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = rows.value.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + (row.items as? ApiRequestPager<*>)?.refreshPagesAfter(position.column) + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } @Composable @@ -130,6 +154,7 @@ fun RecommendedContent( val context = LocalContext.current var moreDialog by remember { mutableStateOf<Optional<RowColumnItem>>(Optional.absent()) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) OneTimeLaunchedEffect { @@ -196,6 +221,7 @@ fun RecommendedContent( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = { viewModel.navigationManager.navigateTo(it) }, @@ -210,6 +236,7 @@ fun RecommendedContent( showPlaylistDialog.makePresent(it) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = RowColumnItem(position, item) }, ), ), onDismissRequest = { moreDialog.makeAbsent() }, @@ -234,9 +261,19 @@ fun RecommendedContent( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } -private data class RowColumnItem( +data class RowColumnItem( val position: RowColumn, val item: BaseItem, ) 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 403ff5fa..392c77a5 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -63,12 +64,14 @@ class RecommendedMovieViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { 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 3258930c..db7f7b4f 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 @@ -14,6 +14,7 @@ import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.LatestNextUpService +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SuggestionService @@ -67,12 +68,14 @@ class RecommendedTvShowViewModel favoriteWatchManager: FavoriteWatchManager, mediaReportService: MediaReportService, backdropService: BackdropService, + mediaManagementService: MediaManagementService, ) : RecommendedViewModel( context, navigationManager, favoriteWatchManager, mediaReportService, backdropService, + mediaManagementService, ) { @AssistedFactory interface Factory { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt index 645ba2ce..f34c0228 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/DetailUtils.kt @@ -29,7 +29,7 @@ data class MoreDialogActions( val onClickFavorite: (UUID, Boolean) -> Unit, val onClickAddPlaylist: (UUID) -> Unit, val onSendMediaInfo: (UUID) -> Unit, - val onClickDelete: (BaseItem) -> Unit = {}, + val onClickDelete: (BaseItem) -> Unit, ) enum class ClearChosenStreams { @@ -63,7 +63,7 @@ fun buildMoreDialogItems( watched: Boolean, favorite: Boolean, canClearChosenStreams: Boolean, - canDelete: Boolean = false, + canDelete: Boolean, actions: MoreDialogActions, onChooseVersion: () -> Unit, onChooseTracks: (MediaStreamType) -> Unit, @@ -236,7 +236,7 @@ fun buildMoreDialogItemsForHome( playbackPosition: Duration, watched: Boolean, favorite: Boolean, - canDelete: Boolean = false, + canDelete: Boolean, actions: MoreDialogActions, ): List<DialogItem> = buildList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 0c7d08b9..a366e788 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -60,7 +60,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo -import com.github.damontecres.wholphin.ui.detail.MoreDialogActions import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt import com.github.damontecres.wholphin.ui.tryRequestFocus @@ -102,15 +101,6 @@ fun DiscoverMovieDetails( val requestStr = stringResource(R.string.request) val request4kStr = stringResource(R.string.request_4k) - val moreActions = - MoreDialogActions( - navigateTo = viewModel::navigateTo, - onClickWatch = { itemId, watched -> }, - onClickFavorite = { itemId, favorite -> }, - onClickAddPlaylist = { itemId -> }, - onSendMediaInfo = {}, - ) - when (val state = loading) { is LoadingState.Error -> { ErrorMessage(state, modifier) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt index 63d5ee5d..ee3cc4a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetails.kt @@ -30,6 +30,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.RequestOrRestoreFocus +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage @@ -82,6 +83,7 @@ fun EpisodeDetails( var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var chooseVersion by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<Optional<UUID>>(Optional.absent()) } + var showDeleteDialog by remember { mutableStateOf<BaseItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val moreActions = @@ -98,6 +100,7 @@ fun EpisodeDetails( showPlaylistDialog.makePresent(itemId) }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { showDeleteDialog = it }, ) when (val state = loading) { @@ -215,6 +218,7 @@ fun EpisodeDetails( onClearChosenStreams = { viewModel.clearChosenStreams(chosenStreams) }, + canDelete = viewModel.canDelete, ), ) }, @@ -224,6 +228,8 @@ fun EpisodeDetails( favoriteOnClick = { viewModel.setFavorite(ep.id, !ep.favorite) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = ep }, modifier = modifier, ) } @@ -276,6 +282,16 @@ fun EpisodeDetails( elevation = 3.dp, ) } + showDeleteDialog?.let { item -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(item) + showDeleteDialog = null + }, + ) + } } private const val HEADER_ROW = 0 @@ -290,6 +306,8 @@ fun EpisodeDetailsContent( watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, moreOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -347,6 +365,8 @@ fun EpisodeDetailsContent( }, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt index 7808141e..9b8bda5c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeViewModel.kt @@ -12,11 +12,13 @@ import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.FavoriteWatchManager +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain @@ -54,6 +56,7 @@ class EpisodeViewModel private val favoriteWatchManager: FavoriteWatchManager, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val mediaManagementService: MediaManagementService, @Assisted val itemId: UUID, ) : ViewModel() { @AssistedFactory @@ -65,6 +68,9 @@ class EpisodeViewModel val item = MutableLiveData<BaseItem?>(null) val chosenStreams = MutableLiveData<ChosenStreams?>(null) + var canDelete: Boolean = false + private set + init { init() } @@ -95,6 +101,7 @@ class EpisodeViewModel ) { val prefs = userPreferencesService.getCurrent() val item = fetchAndSetItem().await() + canDelete = mediaManagementService.canDelete(item) val result = itemPlaybackRepository.getSelectedTracks(item.id, item, prefs) withContext(Dispatchers.Main) { this@EpisodeViewModel.item.value = item @@ -199,4 +206,10 @@ class EpisodeViewModel } } } + + fun deleteItem(item: BaseItem) { + deleteItem(context, mediaManagementService, item) { + navigationManager.goBack() + } + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index ada15158..a59ecde1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -315,6 +315,8 @@ fun MovieDetails( onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, + canDelete = viewModel.canDelete, + deleteOnClick = { showDeleteDialog = movie }, modifier = modifier, ) } @@ -410,6 +412,8 @@ fun MovieDetailsContent( onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -472,6 +476,8 @@ fun MovieDetailsContent( position = TRAILER_ROW trailerOnClick.invoke(it) }, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt index 9b6d81df..2aeac04e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeFooter.kt @@ -25,6 +25,8 @@ fun FocusedEpisodeFooter( moreOnClick: () -> Unit, watchOnClick: () -> Unit, favoriteOnClick: () -> Unit, + canDelete: Boolean, + deleteOnClick: () -> Unit, modifier: Modifier = Modifier, buttonOnFocusChanged: (FocusState) -> Unit = {}, ) { @@ -47,6 +49,8 @@ fun FocusedEpisodeFooter( buttonOnFocusChanged = buttonOnFocusChanged, trailers = null, trailerOnClick = {}, + canDelete = canDelete, + deleteOnClick = deleteOnClick, modifier = Modifier.fillMaxWidth(), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 222f7f77..81beb82f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -584,6 +584,7 @@ fun SeriesDetailsContent( watched = item.played, favorite = item.favorite, actions = moreActions, + canDelete = false, ) moreDialog = DialogParams( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt index 79731e47..3ed615e9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverview.kt @@ -383,6 +383,8 @@ fun SeriesOverview( ), ) }, + canDelete = { viewModel.canDelete(it, preferences.appPreferences) }, + deleteOnClick = { showDeleteDialog = it }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt index a0d120e1..8acc32f1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesOverviewContent.kt @@ -88,6 +88,8 @@ fun SeriesOverviewContent( moreOnClick: () -> Unit, overviewOnClick: () -> Unit, personOnClick: (Person) -> Unit, + canDelete: (BaseItem) -> Boolean, + deleteOnClick: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -294,6 +296,8 @@ fun SeriesOverviewContent( } } }, + canDelete = canDelete.invoke(ep), + deleteOnClick = { deleteOnClick.invoke(ep) }, modifier = Modifier .padding(top = 4.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 0e1b326b..53924431 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -13,6 +13,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager @@ -56,6 +57,9 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -229,6 +233,20 @@ class SeriesViewModel discovered.update { results } } } + mediaManagementService.deletedItemFlow + .onEach { deletedItem -> + if (deletedItem.item.data.seriesId == seriesId) { + Timber.d( + "Item %s deleted from series %s", + deletedItem.item.id, + seriesId, + ) + val seasons = getSeasons(item, seasonEpisodeIds?.seasonNumber).await() + this@SeriesViewModel.seasons.setValueOnMain(seasons) + } + }.catch { ex -> + Timber.e(ex, "Error refreshing after deleted item") + }.launchIn(viewModelScope) } } @@ -614,6 +632,11 @@ class SeriesViewModel } suspend fun canDelete(item: BaseItem): Boolean = mediaManagementService.canDelete(item) + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } sealed interface EpisodeList { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index cf8738c5..3a7a457a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -55,6 +55,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.components.CircularProgress +import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.EpisodeName @@ -62,6 +63,7 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.FocusableItemRow import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails +import com.github.damontecres.wholphin.ui.components.RowColumnItem import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -116,6 +118,7 @@ fun HomePage( LoadingState.Success -> { var dialog by remember { mutableStateOf<DialogParams?>(null) } var showPlaylistDialog by remember { mutableStateOf<UUID?>(null) } + var showDeleteDialog by remember { mutableStateOf<RowColumnItem?>(null) } val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) var position by rememberPosition() HomePageContent( @@ -136,6 +139,7 @@ fun HomePage( playbackPosition = item.playbackPosition, watched = item.played, favorite = item.favorite, + canDelete = viewModel.canDelete(item, preferences.appPreferences), actions = MoreDialogActions( navigateTo = viewModel.navigationManager::navigateTo, @@ -150,6 +154,9 @@ fun HomePage( showPlaylistDialog = itemId }, onSendMediaInfo = viewModel.mediaReportService::sendReportFor, + onClickDelete = { + showDeleteDialog = RowColumnItem(position, item) + }, ), ) dialog = @@ -190,6 +197,16 @@ fun HomePage( elevation = 3.dp, ) } + showDeleteDialog?.let { (position, item) -> + ConfirmDeleteDialog( + itemTitle = listOfNotNull(item.title, item.subtitle).joinToString(" - "), + onCancel = { showDeleteDialog = null }, + onConfirm = { + viewModel.deleteItem(position, item) + showDeleteDialog = null + }, + ) + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt index a8ce7fd9..53417863 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomeViewModel.kt @@ -6,16 +6,21 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig +import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.DatePlayedService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.HomePageResolvedSettings import com.github.damontecres.wholphin.services.HomeSettingsService +import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.MediaReportService import com.github.damontecres.wholphin.services.NavDrawerService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.UserPreferencesService +import com.github.damontecres.wholphin.services.deleteItem import com.github.damontecres.wholphin.services.tvAccess +import com.github.damontecres.wholphin.ui.data.RowColumn +import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.util.ExceptionHandler @@ -52,6 +57,7 @@ class HomeViewModel private val datePlayedService: DatePlayedService, private val backdropService: BackdropService, private val userPreferencesService: UserPreferencesService, + private val mediaManagementService: MediaManagementService, ) : ViewModel() { private val _state = MutableStateFlow(HomeState.EMPTY) val state: StateFlow<HomeState> = _state @@ -189,6 +195,36 @@ class HomeViewModel backdropService.submit(item) } } + + fun deleteItem( + position: RowColumn, + item: BaseItem, + ) { + deleteItem(context, mediaManagementService, item) { + viewModelScope.launchDefault { + val row = state.value.homeRows.getOrNull(position.row) + if (row is HomeRowLoadingState.Success) { + _state.update { + val newRow = + row.items.toMutableList().apply { + removeAt(position.column) + } + it.copy( + homeRows = + it.homeRows.toMutableList().apply { + set(position.row, row.copy(items = newRow)) + }, + ) + } + } + } + } + } + + fun canDelete( + item: BaseItem, + appPreferences: AppPreferences, + ): Boolean = mediaManagementService.canDelete(item, appPreferences) } data class HomeState( From 014bed1bf3cf89f7cadba4d6b051a1723aa8178a Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:30:14 -0500 Subject: [PATCH 176/176] Better logic for choosing the display mode (#1039) ## Description This PR improves the logic used to choose a display mode when refresh rate and resolution switching are enabled. As before, matching the refresh rate is prioritized over picking the resolution. For example, with the display modes of 1080@30 & 720@60 for a 720@30 video, 1080@30 is used because the frame rate exactly matches. ### Related issues Fixes #1036 ### Testing Unit tests, added test cases for more situations ## Screenshots N/A ## AI or LLM usage None --- .../wholphin/services/RefreshRateService.kt | 39 ++++- .../wholphin/test/TestDisplayModeChoice.kt | 164 +++++++++++++++++- 2 files changed, 188 insertions(+), 15 deletions(-) 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 2ca3d1b1..06d116a4 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 @@ -158,33 +158,58 @@ class RefreshRateService if (refreshRateSwitch) { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } - .filter { - it.refreshRateRounded % streamRate == 0 || // Exact multiple - it.refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps - } + .filter { frameRateMatches(it.refreshRateRounded, streamRate) } } else { displayModes .filterNot { it.physicalHeight < streamHeight || it.physicalWidth < streamWidth } } // Timber.v("display modes candidates: %s", candidates.joinToString("\n")) return if (!resolutionSwitch) { - candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } + candidates + .filter { it.refreshRateRounded == streamRate } + .maxByOrNull { it.physicalWidth * it.physicalHeight } + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } else { + // Exact resolution & frame rate candidates.firstOrNull { it.physicalWidth == streamWidth && it.physicalHeight == streamHeight && it.refreshRateRounded == streamRate } - ?: candidates.firstOrNull { - it.physicalWidth == streamWidth && + // Next highest resolution, exact frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && it.physicalHeight >= streamHeight && it.refreshRateRounded == streamRate } + // Exact resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth == streamWidth && + it.physicalHeight == streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Next highest resolution, acceptable frame rate + ?: candidates.lastOrNull { + it.physicalWidth >= streamWidth && + it.physicalHeight >= streamHeight && + frameRateMatches(it.refreshRateRounded, streamRate) + } + // Fall back to largest resolution, exact frame rate ?: candidates .filter { it.refreshRateRounded == streamRate } .maxByOrNull { it.physicalWidth * it.physicalHeight } + // Finally, just the highest resolution + ?: candidates.maxByOrNull { it.physicalWidth * it.physicalHeight } } } + + fun frameRateMatches( + refreshRateRounded: Int, + streamRate: Int, + ): Boolean { + return refreshRateRounded % streamRate == 0 || // Exact multiple + refreshRateRounded == (streamRate * 2.5).roundToInt() // eg 24 & 60fps + } } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt index 7af1bfce..d7e4abfd 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestDisplayModeChoice.kt @@ -15,7 +15,25 @@ class TestDisplayModeChoice { val UHD_30 = DisplayMode(4, 3840, 2160, 30f) val UHD_24 = DisplayMode(5, 3840, 2160, 24f) - val ALL_MODES = listOf(UHD_24, UHD_30, UHD_60, HD_24, HD_30, HD_60) + val HD720_60 = DisplayMode(6, 1280, 720, 60f) + val HD720_30 = DisplayMode(7, 1280, 720, 30f) + val HD720_24 = DisplayMode(8, 1280, 720, 24f) + + val ALL_MODES = + listOf( + UHD_24, + UHD_30, + UHD_60, + HD_24, + HD_30, + HD_60, + HD720_60, + HD720_30, + HD720_24, + ).sortedWith( + compareByDescending<DisplayMode>({ it.physicalWidth * it.physicalHeight }) + .thenBy { it.refreshRateRounded }, + ) } @Test @@ -32,7 +50,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(3, result?.modeId) + Assert.assertEquals(UHD_60.modeId, result?.modeId) } @Test @@ -49,7 +67,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = true, ) - Assert.assertEquals(0, result?.modeId) + Assert.assertEquals(HD_60.modeId, result?.modeId) } @Test @@ -66,7 +84,7 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(4, result?.modeId) + Assert.assertEquals(UHD_30.modeId, result?.modeId) } @Test @@ -83,14 +101,14 @@ class TestDisplayModeChoice { refreshRateSwitch = false, resolutionSwitch = true, ) - Assert.assertEquals(1, result?.modeId) + Assert.assertEquals(HD_30.modeId, result?.modeId) } @Test fun testFraction() { val streamWidth = 1920 val streamHeight = 1080 - val streamRealFrameRate = 30f + val streamRealFrameRate = 29.970f val displayModes = listOf( @@ -104,7 +122,7 @@ class TestDisplayModeChoice { displayModes = displayModes, streamWidth = streamWidth, streamHeight = streamHeight, - targetFrameRate = 29.970f, + targetFrameRate = streamRealFrameRate, refreshRateSwitch = true, resolutionSwitch = false, ) @@ -119,6 +137,136 @@ class TestDisplayModeChoice { refreshRateSwitch = true, resolutionSwitch = false, ) - Assert.assertEquals(1, result2?.modeId) + Assert.assertEquals(HD_30.modeId, result2?.modeId) + } + + private fun test( + expected: DisplayMode, + streamWidth: Int, + streamHeight: Int, + streamRealFrameRate: Float, + ) { + val result = + RefreshRateService.findDisplayMode( + displayModes = ALL_MODES, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = false, + resolutionSwitch = true, + ) + Assert.assertEquals( + "streamWidth=$streamWidth, streamHeight=$streamHeight, streamRealFrameRate=$streamRealFrameRate", + expected.modeId, + result?.modeId, + ) + } + + @Test + fun `Test choose best resolution`() { + test(HD720_30, 1280, 720, 30f) + test(HD720_30, 1280, 548, 30f) + test(HD720_30, 640, 480, 30f) + test(HD720_30, 960, 720, 30f) + test(HD720_24, 960, 720, 24f) + } + + @Test + fun `Test 60fps for 24 without resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + UHD_60, + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = false, + ) + Assert.assertEquals(UHD_60.modeId, result?.modeId) + } + + @Test + fun `Test 60fps for 24 with resolution switch`() { + val streamWidth = 1920 + val streamHeight = 1080 + val streamRealFrameRate = 24f + + val displayModes = + listOf( + HD_60, + HD_30, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_60.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 24f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_24, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_24.modeId, result?.modeId) + } + + @Test + fun `Test prefer refresh rate over resolution2`() { + val streamWidth = 1280 + val streamHeight = 720 + val streamRealFrameRate = 30f + + // 720@60 is an acceptable refresh rate, but want to prioritize exact refresh rate + val displayModes = + listOf( + HD_30, + HD720_60, + ) + + val result = + RefreshRateService.findDisplayMode( + displayModes = displayModes, + streamWidth = streamWidth, + streamHeight = streamHeight, + targetFrameRate = streamRealFrameRate, + refreshRateSwitch = true, + resolutionSwitch = true, + ) + Assert.assertEquals(HD_30.modeId, result?.modeId) } }