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 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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 09/10] 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 10/10] 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), + ) + } +}