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: ""
labels: [
"enhancement"
]
diff --git a/.github/ISSUE_TEMPLATE/00-bug.yml b/.github/ISSUE_TEMPLATE/00-bug.yml
index f67a7db7..1aab31da 100644
--- a/.github/ISSUE_TEMPLATE/00-bug.yml
+++ b/.github/ISSUE_TEMPLATE/00-bug.yml
@@ -1,6 +1,6 @@
name: "Bug Report"
description: Report a bug
-title: "[BUG] - "
+title: ""
labels: [
"bug"
]
@@ -28,7 +28,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 +36,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..34dc317b 100644
--- a/.github/ISSUE_TEMPLATE/01-playback.yml
+++ b/.github/ISSUE_TEMPLATE/01-playback.yml
@@ -1,6 +1,6 @@
name: "Playback Problem"
description: Report an issue with playback
-title: "[BUG] - "
+title: ""
labels: [
"bug", "playback"
]
@@ -17,6 +17,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 +36,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 +44,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] - "
-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..2ee91c39
--- /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=q-a
+ about: Please ask questions here
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
-
+
## Features
@@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
### Customized home page

-
### Movie library browsing
-
+
### Movie page
-
+
### Series page
-
+
+
+### Genres in library
+
+
### Playlist
-
+
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..5a6e446f 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
@@ -74,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
@@ -134,6 +143,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
+ @Inject
+ lateinit var screensaverService: ScreensaverService
+
private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class)
@@ -157,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)
@@ -317,6 +343,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 +359,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 +388,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/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/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/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,
) {
- private val sharedPreferences = getServerSharedPreferences(context)
-
private var _current = EqualityMutableLiveData(null)
val current: LiveData = _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/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/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/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 f0de9d24..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,18 +28,16 @@ class ImageUrlService
itemType: BaseItemKind,
seriesId: UUID?,
useSeriesForPrimary: Boolean,
- imageTags: Map,
imageType: ImageType,
+ imageTags: Map,
+ backdropTags: List,
parentThumbId: UUID? = null,
parentBackdropId: UUID? = null,
- backdropTags: List = emptyList(),
fillWidth: Int? = null,
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/NavDrawerService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/NavDrawerService.kt
index 94551700..4f30ce3c 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
@@ -12,12 +12,14 @@ 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.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
@@ -64,7 +66,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/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 =
@@ -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/services/ScreensaverService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
new file mode 100644
index 00000000..5d73ec21
--- /dev/null
+++ b/app/src/main/java/com/github/damontecres/wholphin/services/ScreensaverService.kt
@@ -0,0 +1,218 @@
+package com.github.damontecres.wholphin.services
+
+import android.content.Context
+import coil3.imageLoader
+import coil3.request.ImageRequest
+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
+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.flowOn
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+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
+
+ val keepScreenOn = MutableStateFlow(false)
+
+ 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.launchDefault {
+ 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 fun keepScreenOnInternal(keep: Boolean) {
+ keepScreenOn.update { keep }
+ }
+
+ 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) {
+ try {
+ context.imageLoader
+ .enqueue(
+ ImageRequest
+ .Builder(context)
+ .data(backdropUrl)
+ .build(),
+ ).job
+ .await()
+ emit(CurrentItem(item, backdropUrl, logoUrl, title ?: ""))
+ } catch (_: CancellationException) {
+ break
+ }
+ val duration =
+ userPreferencesService
+ .getCurrent()
+ .appPreferences
+ .interfacePreferences.screensaverPreference.duration.milliseconds
+ delay(duration)
+ }
+ }
+ index++
+ }
+ }
+ }.flowOn(Dispatchers.Default).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 7ed51113..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,7 +16,7 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore,
) {
- val flow = preferencesDataStore.data
+ val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig ->
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 c997efd1..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
@@ -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
@@ -46,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
@@ -296,7 +294,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 +308,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.
*
@@ -442,12 +428,6 @@ fun Response.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/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/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/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/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/detail/music/NowPlayingPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
index 81a2b335..9da83cc9 100644
--- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
+++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/music/NowPlayingPage.kt
@@ -23,6 +23,7 @@ import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
+import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager
@@ -91,7 +92,13 @@ fun NowPlayingPage(
val current = queue.getOrNull(state.currentIndex)
val controllerViewState = viewModel.controllerViewState
- val preferences by viewModel.userPreferencesService.flow.collectAsState(AppPreferences.getDefaultInstance())
+ val preferences =
+ viewModel.userPreferencesService.flow
+ .collectAsState(
+ UserPreferences(
+ AppPreferences.getDefaultInstance(),
+ ),
+ ).value.appPreferences
val keyHandler =
remember(preferences) {
@@ -111,6 +118,7 @@ fun NowPlayingPage(
// viewModel.navigationManager.goBack()
},
onPlaybackDialogTypeClick = { },
+ getDurationMs = { player.duration },
)
}
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/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,
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/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/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/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackPage.kt
index 0313a8c5..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
@@ -186,7 +186,6 @@ fun PlaybackPageContent(
}
}
- AmbientPlayerListener(player)
var contentScale by remember(playerBackend) {
mutableStateOf(
if (playerBackend == PlayerBackend.MPV) {
@@ -236,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/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/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/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/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,
+ queryParameters: Map,
+ 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)
+}
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 8b2e8d12..98f55a9e 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
@@ -530,6 +535,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
@@ -681,10 +699,30 @@
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
+
+
+ Actor
+ Composer
+ Writer
+ Guest star
+ Producer
+ Conductor
+ Lyricist
+ Arranger
+ Engineer
+ Mixer
+ Creator
+ Artist
+
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),
+ )
+ }
+}
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"