Merge branch 'main' into fea/music

This commit is contained in:
Damontecres 2026-02-28 14:45:15 -05:00
commit bd7fba14d1
No known key found for this signature in database
56 changed files with 1528 additions and 227 deletions

View file

@ -1,6 +1,4 @@
name: "Feature Request" title: "<title>"
description: Request a new feature
title: "[FEA] - <title>"
labels: [ labels: [
"enhancement" "enhancement"
] ]

View file

@ -1,6 +1,6 @@
name: "Bug Report" name: "Bug Report"
description: Report a bug description: Report a bug
title: "[BUG] - <title>" title: "<title>"
labels: [ labels: [
"bug" "bug"
] ]
@ -28,7 +28,7 @@ body:
attributes: attributes:
label: "App Version" label: "App Version"
description: What version of the app? description: What version of the app?
placeholder: "v0.3.0" placeholder: "v0.5.1"
validations: validations:
required: true required: true
- type: input - type: input
@ -36,7 +36,7 @@ body:
attributes: attributes:
label: "Server Version" label: "Server Version"
description: What version of the server? description: What version of the server?
placeholder: "10.11.3" placeholder: "10.11.6"
validations: validations:
required: true required: true
- type: input - type: input

View file

@ -1,6 +1,6 @@
name: "Playback Problem" name: "Playback Problem"
description: Report an issue with playback description: Report an issue with playback
title: "[BUG] - <title>" title: "<title>"
labels: [ labels: [
"bug", "playback" "bug", "playback"
] ]
@ -17,6 +17,7 @@ body:
attributes: attributes:
label: "Media info" label: "Media info"
description: Please enter details about what media you are trying to play (video or audio codecs, container, etc) 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: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -35,7 +36,7 @@ body:
attributes: attributes:
label: "App Version" label: "App Version"
description: What version of the app? description: What version of the app?
placeholder: "v0.3.0" placeholder: "v0.5.1"
validations: validations:
required: true required: true
- type: input - type: input
@ -43,7 +44,7 @@ body:
attributes: attributes:
label: "Server Version" label: "Server Version"
description: What version of the server? description: What version of the server?
placeholder: "10.11.3" placeholder: "10.11.6"
validations: validations:
required: true required: true
- type: input - type: input

View file

@ -1,15 +0,0 @@
name: "Question"
description: Ask a question
title: "[QST] - <title>"
labels: [
"question"
]
body:
- type: textarea
id: description
attributes:
label: "Question"
description: Please enter your question
placeholder: "How do I...?"
validations:
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

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

View file

@ -24,7 +24,7 @@ This is not a fork of the [official client](https://github.com/jellyfin/jellyfin
</p> </p>
<img width="1280" height="720" alt="0_3_5_home" src="https://github.com/user-attachments/assets/a485c015-ec21-442d-a757-1f18381bf799" /> ![v0_5_1_home](https://github.com/user-attachments/assets/62bb1703-abdf-4154-9054-e00b6ceb57b5)
## Features ## Features
@ -117,15 +117,18 @@ You can [help translate Wholphin](https://translate.codeberg.org/engage/wholphin
### Customized home page ### Customized home page
![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1) ![customize_home_example](https://github.com/user-attachments/assets/9a4f04b7-9604-4ea7-b352-50f2b15dc2f1)
### Movie library browsing ### Movie library browsing
<img width="1280" height="771" alt="0 3 0_movies" src="https://github.com/user-attachments/assets/a49829b5-bc2c-4af9-8d5d-2f7d0973ce01" /> ![v0_5_1_library](https://github.com/user-attachments/assets/fad0424b-0631-4438-a8bc-d4fbb95a5bf3)
### Movie page ### Movie page
<img width="1280" height="720" alt="0_3_5_movie" src="https://github.com/user-attachments/assets/86af5889-6761-426a-8649-422f9d0a1dc0" /> ![v0_5_1_movie](https://github.com/user-attachments/assets/849aad34-49d5-4864-8de7-005bbcb68ac6)
### Series page ### Series page
<img width="1280" height="720" alt="0_3_5_series" src="https://github.com/user-attachments/assets/2dcb2260-53ce-49d6-9088-72cbd4563c48" /> ![v0_5_1_series](https://github.com/user-attachments/assets/655389e1-6a6f-43bc-85e1-e2feffb20429)
### Genres in library
![v0_5_1_genres](https://github.com/user-attachments/assets/5bbcbeb6-edc9-42c7-a1d8-d92fa432a498)
### Playlist ### Playlist
<img width="1280" height="771" alt="0 3 0_playlist" src="https://github.com/user-attachments/assets/7ca589ab-9c88-483a-b769-35ffb5663d9e" /> ![v0_5_1_playlist](https://github.com/user-attachments/assets/98268f7d-479d-41c6-b47b-3e67bbe661bc)

View file

@ -68,6 +68,17 @@
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"
tools:node="remove" /> tools:node="remove" />
<service
android:name=".WholphinDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View file

@ -3,10 +3,12 @@ package com.github.damontecres.wholphin
import android.content.Intent import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent
import android.view.WindowManager import android.view.WindowManager
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize 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.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService 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.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager 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.services.tvprovider.TvProviderSchedulerService
import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService 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.components.LoadingPage
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds 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.launchIO
import com.github.damontecres.wholphin.ui.nav.ApplicationContent import com.github.damontecres.wholphin.ui.nav.ApplicationContent
import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.Destination
@ -74,8 +79,12 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.serializer.toUUIDOrNull import org.jellyfin.sdk.model.serializer.toUUIDOrNull
@ -134,6 +143,9 @@ class MainActivity : AppCompatActivity() {
@Inject @Inject
lateinit var datePlayedInvalidationService: DatePlayedInvalidationService lateinit var datePlayedInvalidationService: DatePlayedInvalidationService
@Inject
lateinit var screensaverService: ScreensaverService
private var signInAuto = true private var signInAuto = true
@OptIn(ExperimentalTvMaterial3Api::class) @OptIn(ExperimentalTvMaterial3Api::class)
@ -157,6 +169,20 @@ class MainActivity : AppCompatActivity() {
window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) 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() viewModel.appStart()
setContent { setContent {
val appPreferences by userPreferencesDataStore.data.collectAsState(null) 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() { override fun onResume() {
super.onResume() super.onResume()
Timber.d("onResume") Timber.d("onResume")
lifecycleScope.launchIO { lifecycleScope.launchDefault {
screensaverService.pulse()
appUpgradeHandler.run() appUpgradeHandler.run()
} }
} }
@ -341,6 +388,7 @@ class MainActivity : AppCompatActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
Timber.d("onStop") Timber.d("onStop")
screensaverService.stop(true)
tvProviderSchedulerService.launchOneTimeRefresh() tvProviderSchedulerService.launchOneTimeRefresh()
} }

View file

@ -3,7 +3,6 @@ package com.github.damontecres.wholphin
import android.app.Application import android.app.Application
import android.os.Build import android.os.Build
import android.os.StrictMode import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.util.Log import android.util.Log
import androidx.compose.runtime.Composer import androidx.compose.runtime.Composer
import androidx.compose.runtime.ExperimentalComposeRuntimeApi import androidx.compose.runtime.ExperimentalComposeRuntimeApi
@ -27,16 +26,6 @@ class WholphinApplication :
init { init {
instance = this instance = this
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
ThreadPolicy
.Builder()
.detectNetwork()
.penaltyLog()
.build(),
)
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} else { } else {
@ -64,6 +53,23 @@ class WholphinApplication :
override fun onCreate() { override fun onCreate() {
super.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) OkHttp.initialize(this)
initAcra { initAcra {
buildConfigClass = BuildConfig::class.java buildConfigClass = BuildConfig::class.java

View file

@ -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<UserPreferences?>(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
}
}

View file

@ -42,8 +42,6 @@ class ServerRepository
val apiClient: ApiClient, val apiClient: ApiClient,
val userPreferencesDataStore: DataStore<AppPreferences>, val userPreferencesDataStore: DataStore<AppPreferences>,
) { ) {
private val sharedPreferences = getServerSharedPreferences(context)
private var _current = EqualityMutableLiveData<CurrentUser?>(null) private var _current = EqualityMutableLiveData<CurrentUser?>(null)
val current: LiveData<CurrentUser?> = _current val current: LiveData<CurrentUser?> = _current
@ -107,7 +105,7 @@ class ServerRepository
_current.value = CurrentUser(updatedServer, updatedUser) _current.value = CurrentUser(updatedServer, updatedUser)
_currentUserDto.value = userDto _currentUserDto.value = userDto
} }
sharedPreferences.edit(true) { getServerSharedPreferences(context).edit(true) {
putString(SERVER_URL_KEY, updatedServer.url) putString(SERVER_URL_KEY, updatedServer.url)
putString(ACCESS_TOKEN_KEY, updatedUser.accessToken) putString(ACCESS_TOKEN_KEY, updatedUser.accessToken)
} }

View file

@ -95,7 +95,11 @@ data class BaseItem(
data.indexNumber?.let { "E$it" } data.indexNumber?.let { "E$it" }
?: data.premiereDate?.let(::formatDateTime), ?: data.premiereDate?.let(::formatDateTime),
episodeUnplayedCornerText = 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.indexNumber?.let { "E$it" }
?: data.userData ?: data.userData
?.unplayedItemCount ?.unplayedItemCount

View file

@ -1,6 +1,10 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Stable 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.UUID
@ -19,19 +23,21 @@ data class Person(
) { ) {
companion object { companion object {
fun fromDto( fun fromDto(
context: Context,
dto: BaseItemPerson, dto: BaseItemPerson,
api: ApiClient, api: ApiClient,
): Person = ): Person =
Person( Person(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name,
role = dto.role, role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = false, favorite = false,
) )
fun fromDto( fun fromDto(
context: Context,
dto: BaseItemPerson, dto: BaseItemPerson,
favorite: Boolean, favorite: Boolean,
api: ApiClient, api: ApiClient,
@ -39,10 +45,51 @@ data class Person(
Person( Person(
id = dto.id, id = dto.id,
name = dto.name, name = dto.name,
role = dto.role, role = personRole(context, dto.role, dto.type),
type = dto.type, type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY), imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
favorite = favorite, 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
}

View file

@ -997,6 +997,12 @@ sealed interface AppPreference<Pref, T> {
summaryOn = R.string.enabled, summaryOn = R.string.enabled,
summaryOff = R.string.disabled, summaryOff = R.string.disabled,
) )
val ScreensaverSettings =
AppDestinationPreference<AppPreferences>(
title = R.string.screensaver_settings,
destination = Destination.Settings(PreferenceScreenOption.SCREENSAVER),
)
} }
} }
@ -1011,6 +1017,7 @@ val basicPreferences =
AppPreference.RememberSelectedTab, AppPreference.RememberSelectedTab,
AppPreference.SubtitleStyle, AppPreference.SubtitleStyle,
AppPreference.ThemeColors, AppPreference.ThemeColors,
AppPreference.ScreensaverSettings,
), ),
), ),
PreferenceGroup( PreferenceGroup(
@ -1201,6 +1208,24 @@ val liveTvPreferences =
AppPreference.LiveTvColorCodePrograms, 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<Pref>( data class AppSwitchPreference<Pref>(
@get:StringRes override val title: Int, @get:StringRes override val title: Int,
override val defaultValue: Boolean, override val defaultValue: Boolean,
@ -1255,8 +1280,6 @@ data class AppMultiChoicePreference<Pref, T>(
override val getter: (prefs: Pref) -> List<T>, override val getter: (prefs: Pref) -> List<T>,
override val setter: (prefs: Pref, value: List<T>) -> Pref, override val setter: (prefs: Pref, value: List<T>) -> Pref,
@param:StringRes val summary: Int? = null, @param:StringRes val summary: Int? = null,
val toSharedPrefs: (T) -> String,
val fromSharedPrefs: (String) -> T?,
) : AppPreference<Pref, List<T>> ) : AppPreference<Pref, List<T>>
data class AppClickablePreference<Pref>( data class AppClickablePreference<Pref>(

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.google.protobuf.InvalidProtocolBufferException import com.google.protobuf.InvalidProtocolBufferException
import org.jellyfin.sdk.model.api.BaseItemKind
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import javax.inject.Inject import javax.inject.Inject
@ -120,6 +121,20 @@ class AppPreferencesSerializer
colorCodePrograms = colorCodePrograms =
AppPreference.LiveTvColorCodePrograms.defaultValue AppPreference.LiveTvColorCodePrograms.defaultValue
}.build() }.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() }.build()
advancedPreferences = advancedPreferences =
@ -200,6 +215,11 @@ inline fun AppPreferences.updatePhotoPreferences(block: PhotoPreferences.Builder
photoPreferences = photoPreferences.toBuilder().apply(block).build() 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() { fun SubtitlePreferences.Builder.resetSubtitles() {
fontSize = SubtitleSettings.FontSize.defaultValue.toInt() fontSize = SubtitleSettings.FontSize.defaultValue.toInt()
fontColor = SubtitleSettings.FontColor.defaultValue.toArgb() fontColor = SubtitleSettings.FontColor.defaultValue.toArgb()

View file

@ -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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences>(
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<AppPreferences, BaseItemKind>(
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<AppPreferences>(
title = R.string.start_screensaver,
)
}

View file

@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences 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.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides 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.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.util.Version import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject 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)
}
}
}
} }

View file

@ -52,7 +52,7 @@ class BackdropService
if (item.type == BaseItemKind.GENRE) { if (item.type == BaseItemKind.GENRE) {
item.imageUrlOverride item.imageUrlOverride
} else { } else {
imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)!! imageUrlService.getItemImageUrl(item, ImageType.BACKDROP)
} }
submit(item.id.toString(), imageUrl) submit(item.id.toString(), imageUrl)
} }

View file

@ -28,18 +28,16 @@ class ImageUrlService
itemType: BaseItemKind, itemType: BaseItemKind,
seriesId: UUID?, seriesId: UUID?,
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
imageTags: Map<ImageType, String?>,
imageType: ImageType, imageType: ImageType,
imageTags: Map<ImageType, String?>,
backdropTags: List<String>,
parentThumbId: UUID? = null, parentThumbId: UUID? = null,
parentBackdropId: UUID? = null, parentBackdropId: UUID? = null,
backdropTags: List<String> = emptyList(),
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
): String? = ): String? =
when (imageType) { when (imageType) {
ImageType.BACKDROP, ImageType.LOGO -> {
ImageType.LOGO,
-> {
if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) { if (seriesId != null && (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)) {
getItemImageUrl( getItemImageUrl(
itemId = seriesId, 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 -> { ImageType.THUMB -> {
if (useSeriesForPrimary && parentThumbId != null && if (useSeriesForPrimary && parentThumbId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON) (itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)

View file

@ -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.Destination
import com.github.damontecres.wholphin.ui.nav.NavDrawerItem import com.github.damontecres.wholphin.ui.nav.NavDrawerItem
import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem import com.github.damontecres.wholphin.ui.nav.ServerNavDrawerItem
import com.github.damontecres.wholphin.ui.showToast
import com.github.damontecres.wholphin.util.supportedCollectionTypes import com.github.damontecres.wholphin.util.supportedCollectionTypes
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -64,7 +66,11 @@ class NavDrawerService
if (user != null && userDto != null && user.id == userDto.id) { if (user != null && userDto != null && user.id == userDto.id) {
updateNavDrawer(user, userDto) updateNavDrawer(user, userDto)
} }
}.catch { ex ->
Timber.e(ex, "Error updating nav drawer")
showToast(context, "Error fetching user's views")
}.launchIn(coroutineScope) }.launchIn(coroutineScope)
seerrServerRepository.active seerrServerRepository.active
.onEach { discoverActive -> .onEach { discoverActive ->
_state.update { it.copy(discoverEnabled = discoverActive) } _state.update { it.copy(discoverEnabled = discoverActive) }

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services 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.BaseItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject import javax.inject.Inject
@ -12,6 +14,7 @@ import javax.inject.Singleton
class PeopleFavorites class PeopleFavorites
@Inject @Inject
constructor( constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient, private val api: ApiClient,
) { ) {
suspend fun getPeopleFor(item: BaseItem): List<Person> = suspend fun getPeopleFor(item: BaseItem): List<Person> =
@ -27,7 +30,14 @@ class PeopleFavorites
val people = val people =
item.data.people item.data.people
?.letNotEmpty { 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() }.orEmpty()
people people
} }

View file

@ -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<ScreensaverState> = _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<CurrentItem?> =
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
}

View file

@ -5,6 +5,7 @@ import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -15,7 +16,7 @@ class UserPreferencesService
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>, private val preferencesDataStore: DataStore<AppPreferences>,
) { ) {
val flow = preferencesDataStore.data val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences = suspend fun getCurrent(): UserPreferences =
serverRepository.currentUserDto.value!!.configuration.let { userConfig -> serverRepository.currentUserDto.value!!.configuration.let { userConfig ->

View file

@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.updateInterfacePreferences import com.github.damontecres.wholphin.preferences.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi 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.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module import dagger.Module
@ -111,12 +112,12 @@ object AppModule {
@Singleton @Singleton
fun okHttpFactory( fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient, @StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient) ) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides @Provides
@Singleton @Singleton
fun jellyfin( fun jellyfin(
okHttpFactory: OkHttpFactory, okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context, @ApplicationContext context: Context,
clientInfo: ClientInfo, clientInfo: ClientInfo,
deviceInfo: DeviceInfo, deviceInfo: DeviceInfo,

View file

@ -5,7 +5,6 @@ import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.media.AudioManager import android.media.AudioManager
import android.view.KeyEvent import android.view.KeyEvent
import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.MarqueeAnimationMode import androidx.compose.foundation.MarqueeAnimationMode
import androidx.compose.foundation.basicMarquee 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.api.client.Response
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import timber.log.Timber import timber.log.Timber
import java.util.UUID 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? { fun Context.findActivity(): Activity? {
if (this is Activity) { if (this is Activity) {
@ -310,18 +308,6 @@ fun Context.findActivity(): Activity? {
return null 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. * Selectively log errors from Coil image loading.
* *
@ -442,12 +428,6 @@ fun Response<BaseItemDtoQueryResult>.toBaseItems(
useSeriesForPrimary: Boolean, useSeriesForPrimary: Boolean,
) = this.content.items.map { BaseItem.from(it, api, useSeriesForPrimary) } ) = 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 * Check if this, coalescing nulls to zero, is greater than that
*/ */

View file

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

View file

@ -2,10 +2,8 @@ package com.github.damontecres.wholphin.ui.components
import android.content.Context import android.content.Context
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
@ -802,8 +800,8 @@ fun CollectionFolderGridContent(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader || loadingState !is DataLoadingState.Success, showHeader || loadingState !is DataLoadingState.Success,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),

View file

@ -209,6 +209,7 @@ suspend fun getGenreImageMap(
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(), imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx, fillWidth = cardWidthPx,
backdropTags = item.backdropImageTags.orEmpty(),
) )
} }
} }

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -127,8 +125,8 @@ fun CollectionFolderLiveTv(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -76,8 +74,8 @@ fun CollectionFolderMovie(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -80,8 +78,8 @@ fun CollectionFolderTv(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.detail package com.github.damontecres.wholphin.ui.detail
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -98,8 +96,8 @@ fun FavoritesPage(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -3,6 +3,8 @@ package com.github.damontecres.wholphin.ui.detail.livetv
import android.text.format.DateUtils import android.text.format.DateUtils
import android.widget.Toast import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@ -140,7 +142,11 @@ fun TvGuideGrid(
.fillMaxHeight(.30f), .fillMaxHeight(.30f),
) )
} }
AnimatedVisibility(focusedPosition.row < 1) { AnimatedVisibility(
focusedPosition.row < 1,
enter = expandVertically(),
exit = shrinkVertically(),
) {
ExpandableFaButton( ExpandableFaButton(
title = R.string.view_options, title = R.string.view_options,
iconStringRes = R.string.fa_sliders, iconStringRes = R.string.fa_sliders,

View file

@ -23,6 +23,7 @@ import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences 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.preferences.skipBackOnResume
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.FavoriteWatchManager
@ -91,7 +92,13 @@ fun NowPlayingPage(
val current = queue.getOrNull(state.currentIndex) val current = queue.getOrNull(state.currentIndex)
val controllerViewState = viewModel.controllerViewState 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 = val keyHandler =
remember(preferences) { remember(preferences) {
@ -111,6 +118,7 @@ fun NowPlayingPage(
// viewModel.navigationManager.goBack() // viewModel.navigationManager.goBack()
}, },
onPlaybackDialogTypeClick = { }, onPlaybackDialogTypeClick = { },
getDurationMs = { player.duration },
) )
} }

View file

@ -528,7 +528,7 @@ class SeriesViewModel
api.userLibraryApi api.userLibraryApi
.getItem(item.id) .getItem(item.id)
.content.people .content.people
?.map { Person.fromDto(it, api) } ?.map { Person.fromDto(context, it, api) }
.orEmpty() .orEmpty()
PeopleInItem(item.id, list) PeopleInItem(item.id, list)

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.ui.discover package com.github.damontecres.wholphin.ui.discover
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -60,8 +58,8 @@ fun DiscoverPage(
) { ) {
AnimatedVisibility( AnimatedVisibility(
showHeader, showHeader,
enter = slideInVertically() + fadeIn(), enter = expandVertically(),
exit = slideOutVertically() + fadeOut(), exit = shrinkVertically(),
) { ) {
TabRow( TabRow(
selectedTabIndex = selectedTabIndex, selectedTabIndex = selectedTabIndex,

View file

@ -55,8 +55,8 @@ import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
// Top scrim configuration for text readability (clock, season tabs) // Top scrim configuration for text readability (clock, season tabs)
private const val TOP_SCRIM_ALPHA = 0.55f 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_END_FRACTION = 0.25f // Fraction of backdrop image height
@HiltViewModel @HiltViewModel
class ApplicationContentViewModel class ApplicationContentViewModel

View file

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

View file

@ -20,6 +20,7 @@ class PlaybackKeyHandler(
private val skipWithLeftRight: Boolean, private val skipWithLeftRight: Boolean,
private val seekBack: Duration, private val seekBack: Duration,
private val seekForward: Duration, private val seekForward: Duration,
private val getDurationMs: () -> Long,
private val controllerViewState: ControllerViewState, private val controllerViewState: ControllerViewState,
private val updateSkipIndicator: (Long) -> Unit, private val updateSkipIndicator: (Long) -> Unit,
private val skipBackOnResume: Duration?, private val skipBackOnResume: Duration?,
@ -28,15 +29,22 @@ class PlaybackKeyHandler(
private val onStop: () -> Unit, private val onStop: () -> Unit,
private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit, private val onPlaybackDialogTypeClick: (PlaybackDialogType) -> Unit,
) { ) {
private var leftHandledByRepeat = false
private var rightHandledByRepeat = false
fun onKeyEvent(it: KeyEvent): Boolean { fun onKeyEvent(it: KeyEvent): Boolean {
if (it.type == KeyEventType.KeyUp) onInteraction.invoke() if (it.type == KeyEventType.KeyUp) onInteraction.invoke()
var result = true
if (!controlsEnabled) { if (!controlsEnabled) {
result = false return false
} else if (handleHoldSkip(it)) {
return true
} else if (it.type != KeyEventType.KeyUp) { } else if (it.type != KeyEventType.KeyUp) {
result = false return false
} else if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) { }
var result = true
if (isDirectionalDpad(it) || isEnterKey(it) || isControllerMedia(it)) {
if (!controllerViewState.controlsVisible) { if (!controllerViewState.controlsVisible) {
if (skipWithLeftRight && isSkipBack(it)) { if (skipWithLeftRight && isSkipBack(it)) {
updateSkipIndicator(-seekBack.inWholeMilliseconds) updateSkipIndicator(-seekBack.inWholeMilliseconds)
@ -111,4 +119,84 @@ class PlaybackKeyHandler(
} }
return result 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)
} }

View file

@ -612,29 +612,28 @@ fun Controller(
fontSize = subtitleTextSize, fontSize = subtitleTextSize,
) )
} }
if (showClock) {
var endTimeStr by remember { mutableStateOf("...") } var endTimeStr by remember { mutableStateOf("...") }
LaunchedEffect(playerControls) { LaunchedEffect(playerControls) {
while (isActive) { while (isActive) {
val remaining = val remaining =
(playerControls.duration - playerControls.currentPosition) (playerControls.duration - playerControls.currentPosition)
.div(playerControls.playbackParameters.speed) .div(playerControls.playbackParameters.speed)
.toLong() .toLong()
.milliseconds .milliseconds
val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds) val endTime = LocalTime.now().plusSeconds(remaining.inWholeSeconds)
endTimeStr = TimeFormatter.format(endTime) endTimeStr = TimeFormatter.format(endTime)
delay(1.seconds) 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? // TODO need to move these up a level?

View file

@ -186,7 +186,6 @@ fun PlaybackPageContent(
} }
} }
AmbientPlayerListener(player)
var contentScale by remember(playerBackend) { var contentScale by remember(playerBackend) {
mutableStateOf( mutableStateOf(
if (playerBackend == PlayerBackend.MPV) { if (playerBackend == PlayerBackend.MPV) {
@ -236,6 +235,7 @@ fun PlaybackPageContent(
skipWithLeftRight = true, skipWithLeftRight = true,
seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds, seekForward = preferences.appPreferences.playbackPreferences.skipForwardMs.milliseconds,
seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds, seekBack = preferences.appPreferences.playbackPreferences.skipBackMs.milliseconds,
getDurationMs = { player.duration.coerceAtLeast(0L) },
controllerViewState = controllerViewState, controllerViewState = controllerViewState,
updateSkipIndicator = updateSkipIndicator, updateSkipIndicator = updateSkipIndicator,
skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume, skipBackOnResume = preferences.appPreferences.playbackPreferences.skipBackOnResume,

View file

@ -47,6 +47,7 @@ import com.github.damontecres.wholphin.services.PlayerFactory
import com.github.damontecres.wholphin.services.PlaylistCreationResult import com.github.damontecres.wholphin.services.PlaylistCreationResult
import com.github.damontecres.wholphin.services.PlaylistCreator import com.github.damontecres.wholphin.services.PlaylistCreator
import com.github.damontecres.wholphin.services.RefreshRateService 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.StreamChoiceService
import com.github.damontecres.wholphin.services.UserPreferencesService import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -140,6 +141,7 @@ class PlaybackViewModel
val streamChoiceService: StreamChoiceService, val streamChoiceService: StreamChoiceService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val screensaverService: ScreensaverService,
@Assisted private val destination: Destination, @Assisted private val destination: Destination,
) : ViewModel(), ) : ViewModel(),
Player.Listener, Player.Listener,
@ -189,7 +191,10 @@ class PlaybackViewModel
init { init {
viewModelScope.launchIO { viewModelScope.launchIO {
addCloseable { disconnectPlayer() } addCloseable {
screensaverService.keepScreenOn(false)
disconnectPlayer()
}
init() init()
} }
} }
@ -1424,6 +1429,10 @@ class PlaybackViewModel
} }
} }
} }
override fun onIsPlayingChanged(isPlaying: Boolean) {
screensaverService.keepScreenOn(isPlaying)
}
} }
data class PlayerState( data class PlayerState(

View file

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

View file

@ -46,7 +46,6 @@ import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.ui.handleDPadKeyEvents
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlin.time.Duration import kotlin.time.Duration
@ -80,15 +79,16 @@ fun SteppedSeekBarImpl(
enabled = enabled, enabled = enabled,
progress = progressToUse, progress = progressToUse,
bufferedProgress = bufferedProgress, bufferedProgress = bufferedProgress,
onLeft = { durationMs = durationMs,
onLeft = { multiplier ->
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekProgress = (progressToUse - offset).coerceAtLeast(0f) seekProgress = (progressToUse - offset * multiplier).coerceAtLeast(0f)
hasSeeked = true hasSeeked = true
seek(seekProgress) seek(seekProgress)
}, },
onRight = { onRight = { multiplier ->
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekProgress = (progressToUse + offset).coerceAtMost(1f) seekProgress = (progressToUse + offset * multiplier).coerceAtMost(1f)
hasSeeked = true hasSeeked = true
seek(seekProgress) seek(seekProgress)
}, },
@ -126,16 +126,19 @@ fun IntervalSeekBarImpl(
enabled = enabled, enabled = enabled,
progress = (progressToUse.toDouble() / durationMs).toFloat(), progress = (progressToUse.toDouble() / durationMs).toFloat(),
bufferedProgress = bufferedProgress, bufferedProgress = bufferedProgress,
onLeft = { durationMs = durationMs,
onLeft = { multiplier ->
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekPositionMs = (progressToUse - seekBack.inWholeMilliseconds).coerceAtLeast(0L) seekPositionMs =
(progressToUse - seekBack.inWholeMilliseconds * multiplier).coerceAtLeast(0L)
hasSeeked = true hasSeeked = true
onSeek(seekPositionMs) onSeek(seekPositionMs)
}, },
onRight = { onRight = { multiplier ->
controllerViewState.pulseControls() controllerViewState.pulseControls()
seekPositionMs = seekPositionMs =
(progressToUse + seekForward.inWholeMilliseconds).coerceAtMost(durationMs) (progressToUse + seekForward.inWholeMilliseconds * multiplier)
.coerceAtMost(durationMs)
hasSeeked = true hasSeeked = true
onSeek(seekPositionMs) onSeek(seekPositionMs)
}, },
@ -148,8 +151,9 @@ fun IntervalSeekBarImpl(
fun SeekBarDisplay( fun SeekBarDisplay(
progress: Float, progress: Float,
bufferedProgress: Float, bufferedProgress: Float,
onLeft: () -> Unit, durationMs: Long,
onRight: () -> Unit, onLeft: (Int) -> Unit,
onRight: (Int) -> Unit,
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true, enabled: Boolean = true,
@ -158,14 +162,13 @@ fun SeekBarDisplay(
val onSurface = MaterialTheme.colorScheme.onSurface val onSurface = MaterialTheme.colorScheme.onSurface
val isFocused by interactionSource.collectIsFocusedAsState() val isFocused by interactionSource.collectIsFocusedAsState()
var leftHandledByRepeat by remember { mutableStateOf(false) }
var rightHandledByRepeat by remember { mutableStateOf(false) }
val animatedIndicatorHeight by animateDpAsState( val animatedIndicatorHeight by animateDpAsState(
targetValue = 6.dp.times((if (isFocused) 2f else 1f)), targetValue = 6.dp.times((if (isFocused) 2f else 1f)),
) )
Column( Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(16.dp)) {
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Canvas( Canvas(
modifier = modifier =
Modifier Modifier
@ -173,24 +176,79 @@ fun SeekBarDisplay(
.height(animatedIndicatorHeight) .height(animatedIndicatorHeight)
.padding(horizontal = 4.dp) .padding(horizontal = 4.dp)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
val trigger =
event.type == KeyEventType.KeyUp || event.nativeKeyEvent.repeatCount > 0
when (event.nativeKeyEvent.keyCode) { when (event.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_LEFT -> { 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 return@onPreviewKeyEvent true
} }
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_SYSTEM_NAVIGATION_RIGHT -> { 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 return@onPreviewKeyEvent true
} }
} }
false false
}.handleDPadKeyEvents( }.focusable(enabled = enabled, interactionSource = interactionSource),
onLeft = onLeft,
onRight = onRight,
).focusable(enabled = enabled, interactionSource = interactionSource),
onDraw = { onDraw = {
val yOffset = size.height.div(2) val yOffset = size.height.div(2)
drawLine( drawLine(

View file

@ -43,7 +43,6 @@ import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.SortedSet
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@Composable @Composable
@ -226,7 +225,7 @@ fun <T> ComposablePreference(
} }
is AppMultiChoicePreference<*, *> -> { is AppMultiChoicePreference<*, *> -> {
val values = stringArrayResource(preference.displayValues).toSortedSet() val values = stringArrayResource(preference.displayValues)
val summary = val summary =
preference.summary?.let { stringResource(it) } preference.summary?.let { stringResource(it) }
?: preference.summary(context, value) ?: preference.summary(context, value)
@ -237,12 +236,15 @@ fun <T> ComposablePreference(
list list
} }
MultiChoicePreference( MultiChoicePreference(
possibleValues = values as SortedSet<Any>, possibleValues = preference.allValues,
selectedValues = selectedValues, selectedValues = selectedValues,
title = title, title = title,
summary = summary, summary = summary,
onValueChange = { onValueChange = {
onValueChange.invoke(selectedValues.toList() as T) onValueChange.invoke(it.toList() as T)
},
valueDisplay = { index, _ ->
Text(values[index])
}, },
) )
} }

View file

@ -19,12 +19,12 @@ import com.github.damontecres.wholphin.ui.components.DialogPopup
fun <T> MultiChoicePreference( fun <T> MultiChoicePreference(
title: String, title: String,
summary: String?, summary: String?,
possibleValues: Set<T>, possibleValues: List<T>,
selectedValues: Set<T>, selectedValues: Set<T>,
onValueChange: (List<T>) -> Unit, onValueChange: (List<T>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, 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 values = stringArrayResource(preference.displayValues).toList()
// val summary = // val summary =
@ -59,7 +59,7 @@ fun <T> MultiChoicePreference(
items = items =
possibleValues.mapIndexed { index, item -> possibleValues.mapIndexed { index, item ->
DialogItem( DialogItem(
headlineContent = { valueDisplay.invoke(item) }, headlineContent = { valueDisplay.invoke(index, item) },
trailingContent = { trailingContent = {
Switch( Switch(
checked = selectedValues.contains(item), checked = selectedValues.contains(item),

View file

@ -35,6 +35,7 @@ enum class PreferenceScreenOption {
ADVANCED, ADVANCED,
EXO_PLAYER, EXO_PLAYER,
MPV, MPV,
SCREENSAVER,
; ;
companion object { companion object {

View file

@ -50,8 +50,10 @@ import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences import com.github.damontecres.wholphin.preferences.ExoPlayerPreferences
import com.github.damontecres.wholphin.preferences.MpvPreferences import com.github.damontecres.wholphin.preferences.MpvPreferences
import com.github.damontecres.wholphin.preferences.PlayerBackend 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.advancedPreferences
import com.github.damontecres.wholphin.preferences.basicPreferences 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.preferences.updatePlaybackPreferences
import com.github.damontecres.wholphin.services.SeerrConnectionStatus import com.github.damontecres.wholphin.services.SeerrConnectionStatus
import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.UpdateChecker
@ -128,6 +130,7 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> advancedPreferences PreferenceScreenOption.ADVANCED -> advancedPreferences
PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences PreferenceScreenOption.EXO_PLAYER -> ExoPlayerPreferences
PreferenceScreenOption.MPV -> MpvPreferences PreferenceScreenOption.MPV -> MpvPreferences
PreferenceScreenOption.SCREENSAVER -> screensaverPreferences
} }
val screenTitle = val screenTitle =
when (preferenceScreenOption) { when (preferenceScreenOption) {
@ -135,6 +138,7 @@ fun PreferencesContent(
PreferenceScreenOption.ADVANCED -> R.string.advanced_settings PreferenceScreenOption.ADVANCED -> R.string.advanced_settings
PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options PreferenceScreenOption.EXO_PLAYER -> R.string.exoplayer_options
PreferenceScreenOption.MPV -> R.string.mpv_options PreferenceScreenOption.MPV -> R.string.mpv_options
PreferenceScreenOption.SCREENSAVER -> R.string.screensaver_settings
} }
var visible by remember { mutableStateOf(false) } var visible by remember { mutableStateOf(false) }
@ -283,7 +287,9 @@ fun PreferencesContent(
if (movementSounds) playOnClickSound(context) if (movementSounds) playOnClickSound(context)
if (release != null && updateAvailable) { if (release != null && updateAvailable) {
release?.let { release?.let {
viewModel.navigationManager.navigateTo(Destination.UpdateApp) viewModel.navigationManager.navigateTo(
Destination.UpdateApp,
)
} }
} else { } else {
updateVM.init(preferences.updateUrl) updateVM.init(preferences.updateUrl)
@ -313,7 +319,8 @@ fun PreferencesContent(
val summary = val summary =
remember(cacheUsage) { remember(cacheUsage) {
cacheUsage.let { cacheUsage.let {
val diskMB = it.imageDiskUsed / AppPreference.MEGA_BIT val diskMB =
it.imageDiskUsed / AppPreference.MEGA_BIT
val memoryUsedMB = val memoryUsedMB =
it.imageMemoryUsed / AppPreference.MEGA_BIT it.imageMemoryUsed / AppPreference.MEGA_BIT
val memoryMaxMB = val memoryMaxMB =
@ -392,7 +399,10 @@ fun PreferencesContent(
seerrDialogMode = seerrDialogMode =
when (val conn = seerrConnection) { when (val conn = seerrConnection) {
is SeerrConnectionStatus.Error -> { is SeerrConnectionStatus.Error -> {
SeerrDialogMode.Error(conn.serverUrl, conn.ex) SeerrDialogMode.Error(
conn.serverUrl,
conn.ex,
)
} }
SeerrConnectionStatus.NotConfigured -> { SeerrConnectionStatus.NotConfigured -> {
@ -409,9 +419,19 @@ fun PreferencesContent(
modifier = Modifier, modifier = Modifier,
summary = summary =
when (seerrConnection) { when (seerrConnection) {
is SeerrConnectionStatus.Error -> stringResource(R.string.voice_error_server) is SeerrConnectionStatus.Error -> {
SeerrConnectionStatus.NotConfigured -> stringResource(R.string.add_server) stringResource(R.string.voice_error_server)
is SeerrConnectionStatus.Success -> stringResource(R.string.enabled) }
SeerrConnectionStatus.NotConfigured -> {
stringResource(
R.string.add_server,
)
}
is SeerrConnectionStatus.Success -> {
stringResource(R.string.enabled)
}
}, },
onLongClick = {}, onLongClick = {},
interactionSource = interactionSource, 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 -> { else -> {
val value = pref.getter.invoke(preferences) val value = pref.getter.invoke(preferences)
ComposablePreference( ComposablePreference(
@ -597,6 +630,7 @@ fun PreferencesPage(
PreferenceScreenOption.ADVANCED, PreferenceScreenOption.ADVANCED,
PreferenceScreenOption.EXO_PLAYER, PreferenceScreenOption.EXO_PLAYER,
PreferenceScreenOption.MPV, PreferenceScreenOption.MPV,
PreferenceScreenOption.SCREENSAVER,
-> { -> {
PreferencesContent( PreferencesContent(
initialPreferences, initialPreferences,

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.preferences.resetSubtitles
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager 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.services.SeerrServerRepository
import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs import com.github.damontecres.wholphin.ui.detail.DebugViewModel.Companion.sendAppLogs
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -35,6 +36,7 @@ class PreferencesViewModel
val preferenceDataStore: DataStore<AppPreferences>, val preferenceDataStore: DataStore<AppPreferences>,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
val backdropService: BackdropService, val backdropService: BackdropService,
val screensaverService: ScreensaverService,
private val rememberTabManager: RememberTabManager, private val rememberTabManager: RememberTabManager,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val seerrServerRepository: SeerrServerRepository, private val seerrServerRepository: SeerrServerRepository,

View file

@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.AppColors
import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.LoadingPage 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.nav.Destination
import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad import com.github.damontecres.wholphin.ui.playback.isDirectionalDpad
import com.github.damontecres.wholphin.ui.playback.isDpad import com.github.damontecres.wholphin.ui.playback.isDpad
@ -208,12 +205,6 @@ fun SlideshowPage(
LaunchedEffect(slideshowActive) { LaunchedEffect(slideshowActive) {
player.repeatMode = player.repeatMode =
if (slideshowState.enabled) Player.REPEAT_MODE_OFF else Player.REPEAT_MODE_ONE 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) } var longPressing by remember { mutableStateOf(false) }

View file

@ -18,6 +18,7 @@ import com.github.damontecres.wholphin.data.model.VideoFilter
import com.github.damontecres.wholphin.preferences.AppPreference import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.services.ImageUrlService import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.PlayerFactory 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.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.PhotoItemFields import com.github.damontecres.wholphin.ui.PhotoItemFields
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
@ -67,6 +68,7 @@ class SlideshowViewModel
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val userPreferencesService: UserPreferencesService, private val userPreferencesService: UserPreferencesService,
private val screensaverService: ScreensaverService,
@Assisted val slideshowSettings: Destination.Slideshow, @Assisted val slideshowSettings: Destination.Slideshow,
) : ViewModel(), ) : ViewModel(),
Player.Listener { Player.Listener {
@ -110,6 +112,7 @@ class SlideshowViewModel
init { init {
addCloseable { addCloseable {
screensaverService.keepScreenOn(false)
player.removeListener(this@SlideshowViewModel) player.removeListener(this@SlideshowViewModel)
player.release() player.release()
} }
@ -282,6 +285,7 @@ class SlideshowViewModel
private var slideshowJob: Job? = null private var slideshowJob: Job? = null
fun startSlideshow() { fun startSlideshow() {
screensaverService.keepScreenOn(true)
_slideshow.update { _slideshow.update {
SlideshowState(enabled = true, paused = false) SlideshowState(enabled = true, paused = false)
} }
@ -295,6 +299,7 @@ class SlideshowViewModel
} }
fun stopSlideshow() { fun stopSlideshow() {
screensaverService.keepScreenOn(false)
slideshowJob?.cancel() slideshowJob?.cancel()
_slideshow.update { _slideshow.update {
SlideshowState(enabled = false, paused = false) SlideshowState(enabled = false, paused = false)

View file

@ -0,0 +1,88 @@
package com.github.damontecres.wholphin.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.ApiClientFactory
import org.jellyfin.sdk.api.client.HttpClientOptions
import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.RawResponse
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
import org.jellyfin.sdk.api.sockets.SocketApi
import org.jellyfin.sdk.api.sockets.SocketConnection
import org.jellyfin.sdk.api.sockets.SocketConnectionFactory
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import kotlin.coroutines.CoroutineContext
/**
* Wraps [ApiClient.request] with the given [CoroutineContext]
*/
class CoroutineContextApiClient(
private val client: ApiClient,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : ApiClient() {
override val baseUrl: String?
get() = client.baseUrl
override val accessToken: String?
get() = client.accessToken
override val clientInfo: ClientInfo
get() = client.clientInfo
override val deviceInfo: DeviceInfo
get() = client.deviceInfo
override val httpClientOptions: HttpClientOptions
get() = client.httpClientOptions
override val webSocket: SocketApi
get() = client.webSocket
override fun update(
baseUrl: String?,
accessToken: String?,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
) {
client.update(baseUrl, accessToken, clientInfo, deviceInfo)
}
override suspend fun request(
method: HttpMethod,
pathTemplate: String,
pathParameters: Map<String, Any?>,
queryParameters: Map<String, Any?>,
requestBody: Any?,
): RawResponse =
withContext(coroutineContext) {
client.request(
method,
pathTemplate,
pathParameters,
queryParameters,
requestBody,
)
}
}
class CoroutineContextApiClientFactory(
private val factory: OkHttpFactory,
private val coroutineContext: CoroutineContext = Dispatchers.IO,
) : ApiClientFactory,
SocketConnectionFactory {
override fun create(
baseUrl: String?,
accessToken: String?,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,
httpClientOptions: HttpClientOptions,
socketConnectionFactory: SocketConnectionFactory,
): ApiClient =
CoroutineContextApiClient(
factory.create(baseUrl, accessToken, clientInfo, deviceInfo, httpClientOptions, socketConnectionFactory),
coroutineContext,
)
override fun create(
clientOptions: HttpClientOptions,
scope: CoroutineScope,
): SocketConnection = factory.create(clientOptions, scope)
}

View file

@ -144,6 +144,16 @@ enum BackdropStyle{
BACKDROP_NONE = 2; 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 { message InterfacePreferences {
ThemeSongVolume play_theme_songs = 1; ThemeSongVolume play_theme_songs = 1;
bool remember_selected_tab = 2; bool remember_selected_tab = 2;
@ -155,6 +165,7 @@ message InterfacePreferences {
LiveTvPreferences live_tv_preferences = 8; LiveTvPreferences live_tv_preferences = 8;
BackdropStyle backdrop_style = 9; BackdropStyle backdrop_style = 9;
SubtitlePreferences hdr_subtitles_preferences = 10; SubtitlePreferences hdr_subtitles_preferences = 10;
ScreensaverPreferences screensaver_preference = 11;
} }
message AdvancedPreferences { message AdvancedPreferences {

View file

@ -276,6 +276,11 @@
<item quantity="one">%d second</item> <item quantity="one">%d second</item>
<item quantity="other">%d seconds</item> <item quantity="other">%d seconds</item>
</plurals> </plurals>
<plurals name="minutes">
<item quantity="zero">%s minutes</item>
<item quantity="one">%s minute</item>
<item quantity="other">%s minutes</item>
</plurals>
<plurals name="movies"> <plurals name="movies">
<item quantity="zero">Movies</item> <item quantity="zero">Movies</item>
@ -530,6 +535,19 @@
<string name="display_presets">Display presets</string> <string name="display_presets">Display presets</string>
<string name="display_presets_description">Built-in presets to quickly style all rows</string> <string name="display_presets_description">Built-in presets to quickly style all rows</string>
<string name="customize_home_summary">Choose rows and images on the home page</string> <string name="customize_home_summary">Choose rows and images on the home page</string>
<string name="start_after">Start after</string>
<string name="animate">Animate</string>
<string name="max_age_rating">Max age rating</string>
<string name="no_max">No max</string>
<string name="for_all_ages">For all ages</string>
<string name="up_to_age">Up to age %s</string>
<string name="screensaver">Screensaver</string>
<string name="screensaver_settings">Screensaver settings</string>
<string name="start_screensaver">Start screensaver</string>
<string name="duration">Duration</string>
<string name="photos">Photos</string>
<string name="include_types">Include types</string>
<string name="include_types_summary">What types of items to show images for</string>
<string name="content_scale_fit">Fit</string> <string name="content_scale_fit">Fit</string>
<string name="content_scale_crop">Crop</string> <string name="content_scale_crop">Crop</string>
<string name="content_scale_fill">Fill</string> <string name="content_scale_fill">Fill</string>
@ -681,10 +699,30 @@
<string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string> <string name="search_and_download_subtitles"><![CDATA[Search & download subtitles]]></string>
<string name="no_subtitles_found">No remote subtitles were found</string> <string name="no_subtitles_found">No remote subtitles were found</string>
<string name="series_continueing">Present</string> <string name="series_continueing">Present</string>
<string name="in_app_screensaver">Use in-app screensaver</string>
<string-array name="backdrop_style_options"> <string-array name="backdrop_style_options">
<item>@string/backdrop_style_dynamic</item> <item>@string/backdrop_style_dynamic</item>
<item>@string/backdrop_style_image</item> <item>@string/backdrop_style_image</item>
<item>@string/none</item> <item>@string/none</item>
</string-array> </string-array>
<string-array name="screensaver_item_types">
<item>@string/movies</item>
<item>@string/tv_shows</item>
<item>@string/photos</item>
</string-array>
<string name="actor">Actor</string>
<string name="composer">Composer</string>
<string name="writer">Writer</string>
<string name="guest_star">Guest star</string>
<string name="producer">Producer</string>
<string name="conductor">Conductor</string>
<string name="lyricist">Lyricist</string>
<string name="arranger">Arranger</string>
<string name="engineer">Engineer</string>
<string name="mixer">Mixer</string>
<string name="creator">Creator</string>
<string name="artist">Artist</string>
</resources> </resources>

View file

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

View file

@ -25,7 +25,7 @@ tvMaterial = "1.0.1"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.4" activityCompose = "1.12.4"
androidx-media3 = "1.9.2" androidx-media3 = "1.9.2"
coil = "3.3.0" coil = "3.4.0"
jellyfin-sdk = "1.7.1" jellyfin-sdk = "1.7.1"
nav3Core = "1.0.1" nav3Core = "1.0.1"
lifecycleViewmodelNav3 = "2.10.0" lifecycleViewmodelNav3 = "2.10.0"