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

@ -8,6 +8,7 @@ import androidx.preference.PreferenceManager
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.preferences.AppPreference
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.preferences.ScreensaverPreference
import com.github.damontecres.wholphin.preferences.update
import com.github.damontecres.wholphin.preferences.updateAdvancedPreferences
import com.github.damontecres.wholphin.preferences.updateHomePagePreferences
@ -16,11 +17,13 @@ import com.github.damontecres.wholphin.preferences.updateLiveTvPreferences
import com.github.damontecres.wholphin.preferences.updateMpvOptions
import com.github.damontecres.wholphin.preferences.updatePhotoPreferences
import com.github.damontecres.wholphin.preferences.updatePlaybackOverrides
import com.github.damontecres.wholphin.preferences.updateScreensaverPreferences
import com.github.damontecres.wholphin.preferences.updateSubtitlePreferences
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import com.github.damontecres.wholphin.ui.preferences.subtitle.SubtitleSettings
import com.github.damontecres.wholphin.util.Version
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.io.File
import javax.inject.Inject
@ -246,4 +249,18 @@ suspend fun upgradeApp(
}
}
}
if (previous.isEqualOrBefore(Version.fromString("0.5.0-6-g0"))) {
appPreferences.updateData {
it.updateScreensaverPreferences {
startDelay = ScreensaverPreference.DEFAULT_START_DELAY
duration = ScreensaverPreference.DEFAULT_DURATION
animate = ScreensaverPreference.Animate.defaultValue
maxAgeFilter = ScreensaverPreference.DEFAULT_MAX_AGE
clearItemTypes()
addItemTypes(BaseItemKind.MOVIE.serialName)
addItemTypes(BaseItemKind.SERIES.serialName)
}
}
}
}

View file

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

View file

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

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

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.services
import android.content.Context
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.ui.letNotEmpty
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.itemsApi
import javax.inject.Inject
@ -12,6 +14,7 @@ import javax.inject.Singleton
class PeopleFavorites
@Inject
constructor(
@param:ApplicationContext private val context: Context,
private val api: ApiClient,
) {
suspend fun getPeopleFor(item: BaseItem): List<Person> =
@ -27,7 +30,14 @@ class PeopleFavorites
val people =
item.data.people
?.letNotEmpty { people ->
people.map { Person.fromDto(it, favorites[it.id] ?: false, api) }
people.map {
Person.fromDto(
context,
it,
favorites[it.id] ?: false,
api,
)
}
}.orEmpty()
people
}

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.UserPreferences
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
@ -15,7 +16,7 @@ class UserPreferencesService
private val serverRepository: ServerRepository,
private val preferencesDataStore: DataStore<AppPreferences>,
) {
val flow = preferencesDataStore.data
val flow = preferencesDataStore.data.map { UserPreferences(it) }
suspend fun getCurrent(): UserPreferences =
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.updateInterfacePreferences
import com.github.damontecres.wholphin.services.SeerrApi
import com.github.damontecres.wholphin.util.CoroutineContextApiClientFactory
import com.github.damontecres.wholphin.util.ExceptionHandler
import com.github.damontecres.wholphin.util.RememberTabManager
import dagger.Module
@ -111,12 +112,12 @@ object AppModule {
@Singleton
fun okHttpFactory(
@StandardOkHttpClient okHttpClient: OkHttpClient,
) = OkHttpFactory(okHttpClient)
) = CoroutineContextApiClientFactory(OkHttpFactory(okHttpClient))
@Provides
@Singleton
fun jellyfin(
okHttpFactory: OkHttpFactory,
okHttpFactory: CoroutineContextApiClientFactory,
@ApplicationContext context: Context,
clientInfo: ClientInfo,
deviceInfo: DeviceInfo,